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:
@@ -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)}%`;
|
||||
}
|
||||
Reference in New Issue
Block a user