pcp-tgu-ops-ui: вкладка «Комплексный план» + слой карты

Новая вкладка планирования «Комплексный план» (TguComplexPlanTab) и
одноимённый слой на 2D-карте, выключенный по умолчанию.

- TguComplexPlanTab + complexPlanApi (через прокси /api/pcp-complex,
  добавлен в vite.config.ts) — данные из pcp-complex-mission-service;
- слой complexPlan: тип в mapLayerTypes/mapTypes/mapSelectionTypes,
  отрисовка drawComplexPlan, тоггл и подписи в Tgu2DMapTab/Tgu2DMapView;
- вкладка complex в TguActiveTab, кнопка в TguToolbar, рендер в
  TguPlanningPage; complexPlan: false в дефолтах слоёв и в редакторе/
  заявках/тесте интервала.
This commit is contained in:
Дмитрий Соловьев
2026-06-05 14:51:59 +03:00
parent 69dfd8025d
commit 995396da3b
15 changed files with 430 additions and 6 deletions
@@ -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<ComplexPlanRunResult> {
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<ComplexPlanMode[]> {
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<string, unknown>;
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;
}
@@ -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<ComplexPlanRunResult>();
const [modes, setModes] = useState<ComplexPlanMode[]>([]);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
// 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<Tgu2DMapScene>(() => ({ 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 (
<main className="tgu-map-workspace">
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Комплексный план</div>
<div className="tgu-map-toolbar__subtitle">
Расчёт по {satelliteIds.length} КА вкладки «Планы»
</div>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={setFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={setToValue} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={runCalculation} disabled={!canRun}>
{running ? "Расчёт…" : "Рассчитать"}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{result && (
<div className="tgu-map-toolbar__subtitle">
run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)}
{result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""}
</div>
)}
</div>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={COMPLEX_PLAN_LAYERS}
redrawToken={modes.length}
onObjectSelect={() => undefined}
/>
</section>
<div className="tgu-map-errors">
{satelliteIds.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Нет эксплуатируемых КА на вкладке «Планы» расчёт недоступен.
</div>
)}
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</div>
</main>
);
}
/** Контуры режимов (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)}%`;
}
@@ -85,6 +85,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true, tracks: true,
swath: true, swath: true,
planWorks: true, planWorks: true,
complexPlan: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -312,6 +312,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string {
return "Полоса обзора"; return "Полоса обзора";
case "planWorks": case "planWorks":
return "Работы плана"; return "Работы плана";
case "complexPlan":
return "Комплексный план";
case "stations": case "stations":
return "Станции"; return "Станции";
case "spacecraftMarkers": case "spacecraftMarkers":
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests"; import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations"; import { drawStations } from "./canvas/drawStations";
@@ -198,6 +199,9 @@ export function Tgu2DMapView({
if (layers.swath) { if (layers.swath) {
drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size); 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) { if (layers.planWorks) {
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
} }
@@ -597,6 +601,8 @@ function layerTitle(layer: MapPolygon["layer"]): string {
return "Работы плана"; return "Работы плана";
case "swath": case "swath":
return "Полоса обзора"; return "Полоса обзора";
case "complexPlan":
return "Комплексный план";
case "tracks": case "tracks":
return "Трасса КА"; return "Трасса КА";
case "requests": case "requests":
@@ -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();
}
@@ -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<Tgu2DMapLayerKey, boolean>; export type Tgu2DMapLayersState = Record<Tgu2DMapLayerKey, boolean>;
@@ -30,6 +37,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: false, tracks: false,
swath: false, swath: false,
planWorks: true, planWorks: true,
complexPlan: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -5,7 +5,7 @@ export type Tgu2DMapSelection =
type: "polygon"; type: "polygon";
id: string; id: string;
name: string; name: string;
layer: "planWorks" | "swath" | "tracks" | "requests"; layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
planId?: string; planId?: string;
spacecraftId?: string; spacecraftId?: string;
requestId?: string; requestId?: string;
@@ -24,8 +24,8 @@ export type MapViewState = {
export type MapPolygon = { export type MapPolygon = {
id: string; id: string;
name: string; name: string;
kind: "planWork" | "swath" | "trackZone" | "request"; kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan";
layer: "planWorks" | "swath" | "tracks" | "requests"; layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan";
points: GeoPoint[]; points: GeoPoint[];
status?: TguPlanStatus; status?: TguPlanStatus;
planId?: string; planId?: string;
@@ -51,6 +51,7 @@ describe("tgu2DMapInterval", () => {
tracks: false, tracks: false,
swath: false, swath: false,
planWorks: true, planWorks: true,
complexPlan: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -9,6 +9,7 @@ import {
} from "../../api/tguApi"; } from "../../api/tguApi";
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
@@ -564,6 +565,15 @@ export function TguPlanningPage() {
</div> </div>
)} )}
{visitedTabs.has("complex") && (
<div style={tabStyle("complex")}>
<TguComplexPlanTab
range={appliedRange}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("requests") && ( {visitedTabs.has("requests") && (
<div style={tabStyle("requests")}> <div style={tabStyle("requests")}>
<TguRequestsTab <TguRequestsTab
@@ -56,6 +56,15 @@ export function TguToolbar({
> >
Карта Карта
</button> </button>
<button
className={activeTab === "complex" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "complex"}
onClick={() => onTabChange("complex")}
>
Комплексный план
</button>
<button <button
className={activeTab === "editor" ? "is-active" : ""} className={activeTab === "editor" ? "is-active" : ""}
type="button" type="button"
@@ -123,7 +123,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
); );
const layers = useMemo( const layers = useMemo(
() => ({ tracks: false, swath: false, planWorks: false, stations: showStations, spacecraftMarkers: false, requests: true }), () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }),
[showStations] [showStations]
); );
@@ -22,7 +22,7 @@ export type TimelineRange = {
toMs: number; toMs: number;
}; };
export type TguActiveTab = "timeline" | "map" | "editor" | "requests"; export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests";
export type TimelineRow = { export type TimelineRow = {
spacecraftId: string; spacecraftId: string;
+5
View File
@@ -27,6 +27,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "") rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "")
}, },
"/api/pcp-complex": {
target: "http://localhost:7002",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-complex/, "")
},
"/api/stations": { "/api/stations": {
target: "http://localhost:7009", target: "http://localhost:7009",
changeOrigin: true changeOrigin: true