Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx
T
Дмитрий Соловьев 2499aebf2d routes: объём маршрута (volumeMb) — расчёт, хранение в БД и отображение
Объём режима = скорость × длительность, Мбайт: съёмка 120 Мбайт/с (12 ГБ / 100 с), сброс — канал 1200 Мбит/с ÷ 8. Единый источник — ModeDataRates в pcp-types-lib. Хранится колонкой volume_mb (Flyway V10 mission-planing, V9 complex-mission, бэкфилл из duration), заполняется на всех путях построения через @PrePersist/@PreUpdate. DTO отдают вычисляемое read-only поле (та же формула). Фронт pcp-tgu-ops-ui показывает объём в редакторе (инспектор, построенный маршрут) и в сводке комплексного плана.
2026-06-06 18:34:29 +03:00

332 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import {
fetchComplexPlanRunModes,
fetchComplexPlanRuns,
runComplexPlan,
MAX_COMPLEX_PLAN_RANGE_DAYS,
MAX_COMPLEX_PLAN_RANGE_MS,
type ComplexPlanMode,
type ComplexPlanRunSummary
} from "../../api/complexPlanApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
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 { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
type TguComplexPlanTabProps = {
range: TimelineRange;
/** Все эксплуатируемые КА вкладки «Планы» — по ним считаем комплексный план. */
platforms: TguPlatformUi[];
};
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 [modes, setModes] = useState<ComplexPlanMode[]>([]);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
// История прогонов и выбранный для просмотра прогон.
const [runs, setRuns] = useState<ComplexPlanRunSummary[]>([]);
const [runsError, setRunsError] = useState<string>();
const [selectedRunId, setSelectedRunId] = useState<number>();
const [loadingRun, setLoadingRun] = useState(false);
// Наложение заявок поверх контуров комплексного плана.
const [showRequests, setShowRequests] = useState(false);
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = 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 rangeTooLong =
!rangeInvalid && parsedRange.toMs - parsedRange.fromMs > MAX_COMPLEX_PLAN_RANGE_MS;
const selectedRun = useMemo(
() => runs.find((run) => run.runId === selectedRunId),
[runs, selectedRunId]
);
// Интервал, по которому грузим заявки: показанный прогон (если выбран) либо поля «С/По».
const activeInterval = useMemo(() => {
if (selectedRun) {
return { fromMs: parseLocal(selectedRun.intervalStart), toMs: parseLocal(selectedRun.intervalEnd) };
}
return parsedRange;
}, [selectedRun, parsedRange]);
const modePolygons = useMemo(() => buildModePolygons(modes), [modes]);
const requestPolygons = useMemo(
() => (showRequests ? buildTgu2DMapScene({ range, platforms, requestItems }).polygons : []),
[showRequests, requestItems, range, platforms]
);
const scene = useMemo<Tgu2DMapScene>(
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[modePolygons, requestPolygons]
);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
tracks: false,
swath: false,
planWorks: false,
complexPlan: true,
stations: false,
spacecraftMarkers: false,
requests: showRequests
}),
[showRequests]
);
const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => {
setRunsError(undefined);
try {
const items = await fetchComplexPlanRuns();
setRuns(items);
return items;
} catch (caught) {
setRunsError(caught instanceof Error ? caught.message : "Не удалось загрузить историю расчётов.");
return [];
}
}, []);
const loadRunModes = useCallback(async (runId: number) => {
setSelectedRunId(runId);
setLoadingRun(true);
setError(undefined);
try {
const runModes = await fetchComplexPlanRunModes(runId);
setModes(runModes);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось загрузить результат расчёта.");
} finally {
setLoadingRun(false);
}
}, []);
// На входе во вкладку подтягиваем историю и сразу показываем последний построенный план.
useEffect(() => {
void (async () => {
const items = await loadRuns();
const latest = items.find((run) => run.status === "COMPLETED" && run.calculatedModesCount > 0) ?? items[0];
if (latest) void loadRunModes(latest.runId);
})();
}, [loadRuns, loadRunModes]);
// Заявки за активный интервал (по тумблеру) — для сопоставления контуров плана с заявками.
useEffect(() => {
if (!showRequests) return;
const { fromMs, toMs } = activeInterval;
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
let cancelled = false;
setRequestsLoading(true);
setRequestsError(undefined);
fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) })
.then((result) => {
if (!cancelled) setRequestItems(result.items);
})
.catch((caught) => {
if (!cancelled) setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
})
.finally(() => {
if (!cancelled) setRequestsLoading(false);
});
return () => {
cancelled = true;
};
}, [showRequests, activeInterval]);
const runCalculation = async () => {
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
setRunning(true);
setError(undefined);
setNotice(undefined);
try {
const runResult = await runComplexPlan({
fromMs: parsedRange.fromMs,
toMs: parsedRange.toMs,
satelliteIds
});
await loadRuns();
await loadRunModes(runResult.runId);
setNotice(
runResult.calculatedModesCount > 0
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.`
: "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками."
);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось рассчитать комплексный план.");
} finally {
setRunning(false);
}
};
const canRun = !running && !rangeInvalid && !rangeTooLong && satelliteIds.length > 0;
const busy = running || loadingRun;
const overlayMessage = busy
? running
? "Идёт расчёт…"
: "Загрузка плана…"
: rangeInvalid
? "Некорректный интервал: «С» должно быть раньше «По»."
: modes.length === 0
? "Задайте интервал и нажмите «Рассчитать» либо выберите построенный план из истории."
: undefined;
const displayRun = selectedRun;
const contoursCount = modePolygons.length;
// Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала.
const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0);
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>
<label className="tgu-field">
<span>Построенные планы</span>
<select
className="tgu-select"
value={selectedRunId ?? ""}
disabled={runs.length === 0 || busy}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) void loadRunModes(value);
}}
>
{runs.length === 0 && <option value="">нет расчётов</option>}
{runs.map((run) => (
<option key={run.runId} value={run.runId}>
{formatRunOption(run)}
</option>
))}
</select>
</label>
<button
className={`tgu-button${showRequests ? " is-active" : ""}`}
type="button"
onClick={() => setShowRequests((value) => !value)}
>
{showRequests ? "Скрыть заявки" : "Показать заявки"}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && (
<div className="tgu-map-toolbar__subtitle">
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)}
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""}
</div>
)}
</div>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={modes.length + requestPolygons.length}
onObjectSelect={() => undefined}
/>
</section>
<div className="tgu-map-errors">
{satelliteIds.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Нет эксплуатируемых КА на вкладке «Планы» расчёт недоступен.
</div>
)}
{rangeTooLong && (
<div className="tgu-alert tgu-alert--warning">
Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток сократите диапазон «С/По».
</div>
)}
{requestsLoading && <div className="tgu-alert">Загрузка заявок</div>}
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</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 formatRunOption(run: ComplexPlanRunSummary): string {
const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))}${formatLocalDateTime(parseLocal(run.intervalEnd))}`;
const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`;
return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`;
}
function formatPercent(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return "—";
return `${value.toFixed(1)}%`;
}