diff --git a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts new file mode 100644 index 0000000..e5acb13 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts @@ -0,0 +1,139 @@ +import { TguApiError } from "../model/tguTypes"; +import { toLocalIso } from "../utils/localTime"; + +/** + * Расчёт комплексного плана (этап 4) напрямую в pcp-complex-mission-service через Vite-прокси + * (`/api/pcp-complex` → :7002), как и остальные сервисы — без BFF. Это тот самый snapshot, + * который затем потребляет редактор (stage 5, `calculateSurveys`). + */ +const BASE = "/api/pcp-complex"; + +/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */ +export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED"; + +export type RunComplexPlanParams = { + /** Начало интервала расчёта (эпоха-мс, локальное). */ + fromMs: number; + /** Конец интервала расчёта (эпоха-мс, локальное). */ + toMs: number; + /** NORAD-id КА, по которым считаем (все эксплуатируемые КА вкладки «Планы»). */ + satelliteIds: number[]; +}; + +/** + * Ответ синхронного расчёта комплексного плана (ComplexPlanRunResponseDTO). + * Расчёт уже завершён к моменту ответа: пишет snapshot’ы этапа 4 и возвращает их id + статистику. + */ +export type ComplexPlanRunResult = { + runId: number; + status: ComplexPlanRunStatus; + intervalStart: string; + intervalEnd: string; + satelliteIds: number[]; + durationMs: number | null; + errorMessage: string | null; + calculatedModesCount: number; + bookedModesCount: number; + complanModesCount: number; + mixedModesCount: number; + affectedSatellitesCount: number; + processedCellsCount: number; + bookedSlotLinksCount: number; + snapshotIds: number[]; + totalRequestsArea: number; + coveredArea: number; + coveredAreaPercent: number; +}; + +/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */ +export type ComplexPlanMode = { + id: number; + satelliteId: number; + source: string; + type: "SURVEY" | "DROP" | string; + startTime: string; + endTime: string; + revolution: number; + lat: number; + longitude: number; + duration: number; + /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). Для сбросов обычно отсутствует. */ + contourWkt: string | null; + roll: number; + cellNum: number; + bookedSlotIds: number[]; + snapshotId: number | null; +}; + +/** + * Запуск расчёта комплексного плана на интервал по списку КА + * (pcp-complex-mission-service, POST /api/com-plan/process). Вызов синхронный: возвращает + * завершённый run со статистикой и id записанных snapshot’ов. Бэк отклоняет запуск, если по + * одному из КА уже идёт пересекающийся расчёт. + */ +export async function runComplexPlan(params: RunComplexPlanParams): Promise { + const body = { + intervalStart: toLocalIso(params.fromMs), + intervalEnd: toLocalIso(params.toMs), + satelliteIds: params.satelliteIds + }; + + const url = `${BASE}/api/com-plan/process`; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json", "X-Requested-By": "pcp-tgu-ops-ui" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось рассчитать комплексный план.", "http", response.status); + } + if (!text.trim()) { + throw new TguApiError("Сервис комплексного плана вернул пустой ответ.", "http", response.status); + } + + return JSON.parse(text) as ComplexPlanRunResult; +} + +/** + * Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте + * (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes). + */ +export async function fetchComplexPlanRunModes(runId: number): Promise { + const url = `${BASE}/api/com-plan/runs/${encodeURIComponent(runId)}/modes`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось загрузить результат расчёта.", "http", response.status); + } + if (!text.trim()) return []; + return JSON.parse(text) as ComplexPlanMode[]; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx new file mode 100644 index 0000000..fd0725e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -0,0 +1,182 @@ +import { useMemo, useState } from "react"; +import { + fetchComplexPlanRunModes, + runComplexPlan, + type ComplexPlanMode, + type ComplexPlanRunResult +} from "../../api/complexPlanApi"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { parseLocal, toLocalInputValue } from "../../utils/localTime"; + +type TguComplexPlanTabProps = { + range: TimelineRange; + /** Все эксплуатируемые КА вкладки «Планы» — по ним считаем комплексный план. */ + platforms: TguPlatformUi[]; +}; + +// Включён только выделенный слой «Комплексный план» (свой цвет, своя отрисовка с фиксом +// антимеридиана) — контуры не путаются с полосой обзора/работами плана других вкладок. +const COMPLEX_PLAN_LAYERS: Tgu2DMapLayersState = { + tracks: false, + swath: false, + planWorks: false, + complexPlan: true, + stations: false, + spacecraftMarkers: false, + requests: false +}; + +export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) { + const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(); + const [modes, setModes] = useState([]); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). + const satelliteIds = useMemo( + () => + Array.from( + new Set( + platforms + .map((platform) => Number(platform.spacecraftId)) + .filter((id) => Number.isFinite(id)) + ) + ), + [platforms] + ); + + const parsedRange = useMemo( + () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), + [fromValue, toValue] + ); + const rangeInvalid = + !Number.isFinite(parsedRange.fromMs) || + !Number.isFinite(parsedRange.toMs) || + parsedRange.fromMs >= parsedRange.toMs; + + const scene = useMemo(() => ({ polygons: buildModePolygons(modes), lines: [], markers: [] }), [modes]); + + const runCalculation = async () => { + if (running || rangeInvalid || satelliteIds.length === 0) return; + setRunning(true); + setError(undefined); + setNotice(undefined); + try { + const runResult = await runComplexPlan({ + fromMs: parsedRange.fromMs, + toMs: parsedRange.toMs, + satelliteIds + }); + setResult(runResult); + const runModes = await fetchComplexPlanRunModes(runResult.runId); + setModes(runModes); + const contoursCount = runModes.filter((mode) => mode.contourWkt).length; + setNotice( + runResult.calculatedModesCount > 0 + ? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}. На карте — ${contoursCount} контуров.` + : "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками." + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось рассчитать комплексный план."); + } finally { + setRunning(false); + } + }; + + const canRun = !running && !rangeInvalid && satelliteIds.length > 0; + const overlayMessage = running + ? "Идёт расчёт…" + : rangeInvalid + ? "Некорректный интервал: «С» должно быть раньше «По»." + : modes.length === 0 + ? "Задайте интервал и нажмите «Рассчитать», чтобы построить комплексный план." + : undefined; + + return ( +
+
+
+
Комплексный план
+
+ Расчёт по {satelliteIds.length} КА вкладки «Планы» +
+
+ + + +
+ {result && ( +
+ run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)} + {result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""} +
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + undefined} + /> +
+ +
+ {satelliteIds.length === 0 && ( +
+ Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен. +
+ )} + {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ +function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { + const polygons: MapPolygon[] = []; + let zIndex = 0; + for (const mode of modes) { + if (!mode.contourWkt) continue; + const rings = parseWktPolygon(mode.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `complex-mode:${mode.id}:${zIndex}`, + name: `КА ${mode.satelliteId} · виток ${mode.revolution}`, + kind: "complexPlan", + layer: "complexPlan", + points: ring, + spacecraftId: String(mode.satelliteId), + modeId: mode.id, + zIndex: zIndex++ + }); + } + } + return polygons; +} + +function formatPercent(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + return `${value.toFixed(1)}%`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index e51ba7f..1998bc0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -85,6 +85,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 62db2bf..b02477a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -312,6 +312,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string { return "Полоса обзора"; case "planWorks": return "Работы плана"; + case "complexPlan": + return "Комплексный план"; case "stations": return "Станции"; case "spacecraftMarkers": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 69d9515..ac200bb 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; +import { drawComplexPlan } from "./canvas/drawComplexPlan"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; import { drawStations } from "./canvas/drawStations"; @@ -198,6 +199,9 @@ export function Tgu2DMapView({ if (layers.swath) { drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size); } + if (layers.complexPlan) { + drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); + } if (layers.planWorks) { drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); } @@ -597,6 +601,8 @@ function layerTitle(layer: MapPolygon["layer"]): string { return "Работы плана"; case "swath": return "Полоса обзора"; + case "complexPlan": + return "Комплексный план"; case "tracks": return "Трасса КА"; case "requests": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts new file mode 100644 index 0000000..367baf2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts @@ -0,0 +1,61 @@ +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapPolygon } from "../model/mapTypes"; + +type Pt = { x: number; y: number }; + +/** + * «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на + * полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту. + * Та же логика, что в drawSwath/drawPlanWorks — фикс антимеридиана. + */ +function unwrapX(points: Pt[], worldSize: number): Pt[] { + if (points.length === 0) return []; + const out: Pt[] = [points[0]]; + let prevX = points[0].x; + for (let i = 1; i < points.length; i++) { + let x = points[i].x; + while (x - prevX > worldSize / 2) x -= worldSize; + while (prevX - x > worldSize / 2) x += worldSize; + out.push({ x, y: points[i].y }); + prevX = x; + } + return out; +} + +/** + * Контуры комплексного плана (этап 4). Отдельный слой со своим (янтарным) цветом, чтобы + * визуально не путать с «полосой обзора» (swath) и «работами плана» (planWorks). Рисуется + * в трёх копиях мира с отсечением по видимой области — как остальные полигональные слои. + */ +export function drawComplexPlan( + ctx: CanvasRenderingContext2D, + polygons: MapPolygon[], + projection: MapProjection, + viewport: { width: number; height: number } +) { + ctx.save(); + ctx.fillStyle = "rgba(240, 170, 70, 0.14)"; + ctx.strokeStyle = "rgba(240, 170, 70, 0.62)"; + ctx.lineWidth = 1.2; + for (const polygon of polygons) { + if (polygon.points.length < 3) continue; + const raw = polygon.points.map((point) => projection.project(point)); + const pts = unwrapX(raw, projection.worldSize); + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((point) => { + const x = point.x + dx; + return x >= -64 && x <= viewport.width + 64 && point.y >= -64 && point.y <= viewport.height + 64; + }); + if (!visible) continue; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y); + else ctx.lineTo(pts[i].x + dx, pts[i].y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts index 502cb80..6dff8ab 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -1,4 +1,11 @@ -export type Tgu2DMapLayerKey = "tracks" | "swath" | "planWorks" | "stations" | "spacecraftMarkers" | "requests"; +export type Tgu2DMapLayerKey = + | "tracks" + | "swath" + | "planWorks" + | "complexPlan" + | "stations" + | "spacecraftMarkers" + | "requests"; export type Tgu2DMapLayersState = Record; @@ -30,6 +37,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { tracks: false, swath: false, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index 89f14d3..e2af403 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -5,7 +5,7 @@ export type Tgu2DMapSelection = type: "polygon"; id: string; name: string; - layer: "planWorks" | "swath" | "tracks" | "requests"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; planId?: string; spacecraftId?: string; requestId?: string; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index e7981e1..227bf17 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -24,8 +24,8 @@ export type MapViewState = { export type MapPolygon = { id: string; name: string; - kind: "planWork" | "swath" | "trackZone" | "request"; - layer: "planWorks" | "swath" | "tracks" | "requests"; + kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; points: GeoPoint[]; status?: TguPlanStatus; planId?: string; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 0732a54..1d5f154 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -51,6 +51,7 @@ describe("tgu2DMapInterval", () => { tracks: false, swath: false, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index f00b9c1..eccbe4b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -9,6 +9,7 @@ import { } from "../../api/tguApi"; import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; +import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; @@ -564,6 +565,15 @@ export function TguPlanningPage() { )} + {visitedTabs.has("complex") && ( +
+ +
+ )} + {visitedTabs.has("requests") && (
Карта +