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,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)}%`;
}