2499aebf2d
Объём режима = скорость × длительность, Мбайт: съёмка 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 показывает объём в редакторе (инспектор, построенный маршрут) и в сводке комплексного плана.
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
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)}%`;
|
||
}
|