pcp-tgu-ops-ui: правки API-клиентов, вкладок и стилей
complexPlanApi/missionPlanningApi, TguComplexPlanTab, TguEditorTab, стили tgu-editor.css/tgu-planning.css.
This commit is contained in:
@@ -1,17 +1,23 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchComplexPlanRunModes,
|
||||
fetchComplexPlanRuns,
|
||||
runComplexPlan,
|
||||
MAX_COMPLEX_PLAN_RANGE_DAYS,
|
||||
MAX_COMPLEX_PLAN_RANGE_MS,
|
||||
type ComplexPlanMode,
|
||||
type ComplexPlanRunResult
|
||||
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 { parseLocal, toLocalInputValue } from "../../utils/localTime";
|
||||
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
||||
|
||||
type TguComplexPlanTabProps = {
|
||||
range: TimelineRange;
|
||||
@@ -19,27 +25,26 @@ type TguComplexPlanTabProps = {
|
||||
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>();
|
||||
|
||||
// История прогонов и выбранный для просмотра прогон.
|
||||
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(
|
||||
() =>
|
||||
@@ -61,11 +66,106 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
!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 scene = useMemo<Tgu2DMapScene>(() => ({ polygons: buildModePolygons(modes), lines: [], markers: [] }), [modes]);
|
||||
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 || satelliteIds.length === 0) return;
|
||||
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
|
||||
setRunning(true);
|
||||
setError(undefined);
|
||||
setNotice(undefined);
|
||||
@@ -75,13 +175,11 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
toMs: parsedRange.toMs,
|
||||
satelliteIds
|
||||
});
|
||||
setResult(runResult);
|
||||
const runModes = await fetchComplexPlanRunModes(runResult.runId);
|
||||
setModes(runModes);
|
||||
const contoursCount = runModes.filter((mode) => mode.contourWkt).length;
|
||||
await loadRuns();
|
||||
await loadRunModes(runResult.runId);
|
||||
setNotice(
|
||||
runResult.calculatedModesCount > 0
|
||||
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}. На карте — ${contoursCount} контуров.`
|
||||
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.`
|
||||
: "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками."
|
||||
);
|
||||
} catch (caught) {
|
||||
@@ -91,15 +189,21 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
}
|
||||
};
|
||||
|
||||
const canRun = !running && !rangeInvalid && satelliteIds.length > 0;
|
||||
const overlayMessage = running
|
||||
? "Идёт расчёт…"
|
||||
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;
|
||||
|
||||
return (
|
||||
<main className="tgu-map-workspace">
|
||||
<div className="tgu-map-toolbar">
|
||||
@@ -120,11 +224,38 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
<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 }} />
|
||||
{result && (
|
||||
{displayRun && (
|
||||
<div className="tgu-map-toolbar__subtitle">
|
||||
run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)}
|
||||
{result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""}
|
||||
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)}
|
||||
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
|
||||
{showRequests ? ` · заявок ${requestItems.length}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,8 +264,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
||||
<Tgu2DMapView
|
||||
scene={scene}
|
||||
layers={COMPLEX_PLAN_LAYERS}
|
||||
redrawToken={modes.length}
|
||||
layers={layers}
|
||||
redrawToken={modes.length + requestPolygons.length}
|
||||
onObjectSelect={() => undefined}
|
||||
/>
|
||||
</section>
|
||||
@@ -145,6 +276,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
||||
Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен.
|
||||
</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>
|
||||
@@ -176,6 +315,13 @@ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] {
|
||||
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)}%`;
|
||||
|
||||
Reference in New Issue
Block a user