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([]); const [error, setError] = useState(); const [notice, setNotice] = useState(); // История прогонов и выбранный для просмотра прогон. const [runs, setRuns] = useState([]); const [runsError, setRunsError] = useState(); const [selectedRunId, setSelectedRunId] = useState(); const [loadingRun, setLoadingRun] = useState(false); // Наложение заявок поверх контуров комплексного плана. const [showRequests, setShowRequests] = useState(false); const [requestItems, setRequestItems] = useState([]); const [requestsLoading, setRequestsLoading] = useState(false); const [requestsError, setRequestsError] = 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 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( () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), [modePolygons, requestPolygons] ); const layers = useMemo( () => ({ tracks: false, swath: false, planWorks: false, complexPlan: true, stations: false, spacecraftMarkers: false, requests: showRequests }), [showRequests] ); const loadRuns = useCallback(async (): Promise => { 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 (
Комплексный план
Расчёт по {satelliteIds.length} КА вкладки «Планы»
{displayRun && (
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {showRequests ? ` · заявок ${requestItems.length}` : ""}
)}
{overlayMessage &&
{overlayMessage}
} undefined} />
{satelliteIds.length === 0 && (
Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен.
)} {rangeTooLong && (
Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток — сократите диапазон «С/По».
)} {requestsLoading &&
Загрузка заявок…
} {requestsError &&
{requestsError}
} {runsError &&
{runsError}
} {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 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)}%`; }