Редактор: ручное добавление маршрутов съёмки и сбросов

Режим «Сброс» теперь рабочий: загружает ЗРВ станций
(ballisticsApi.fetchSatelliteRva), даёт оператору выбрать зону, затем
показывает съёмки плана, заканчивающиеся строго до начала зоны, и
сохраняет сброс в плане (missionPlanningApi.saveDropRoute → DropPanel).
Старый фейковый DownlinkPanel на stationPasses убран из потока.
Режим «Съёмка» сохраняет маршрут через saveSurveyRoute.

Времена ЗРВ приходят как LocalDateTime без зоны и трактуются как UTC
(helper localDateTimeMs), чтобы сравнивать с временами съёмок.
В vite-прокси добавлены pcp-ballistics, pcp-mission и stations.
This commit is contained in:
Дмитрий Соловьев
2026-06-01 22:17:32 +03:00
parent ef97ebbcf7
commit 715b5108d6
9 changed files with 1069 additions and 89 deletions
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, targetPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi";
import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi";
import {
addDownlink,
addShoot,
@@ -21,7 +26,7 @@ import {
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
@@ -39,9 +44,17 @@ import type {
TguEditorTabProps
} from "./model/editorTypes";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
const HOUR_MS = 60 * 60 * 1000;
/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */
const SHOOT_WINDOW_MS = 6 * 60 * 1000;
/**
* Угол захвата (полуширина полосы) по умолчанию, градусы.
* Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем
* это значение будет определять бэкенд по профилю наблюдения КА.
*/
const DEFAULT_CAPTURE_ANGLE_DEG = 1.5;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
@@ -55,7 +68,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const centerRef = useRef<HTMLElement>(null);
const [mapRatio, setMapRatio] = useState(0.5);
const [mapRatio, setMapRatio] = useState(0.72);
const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
@@ -67,7 +80,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [target, setTarget] = useState<EditorTarget>();
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
const [selectedDownlink, setSelectedDownlink] = useState<DownlinkSelection>();
const [rvaZones, setRvaZones] = useState<RvaZone[]>([]);
const [rvaLoading, setRvaLoading] = useState(false);
const [rvaError, setRvaError] = useState<string>();
const [selectedZone, setSelectedZone] = useState<RvaZone>();
const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState<Set<number>>(new Set());
const [dropSaving, setDropSaving] = useState(false);
const [dropError, setDropError] = useState<string>();
const [mapInfoSelection, setMapInfoSelection] = useState<Tgu2DMapSelection>();
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@@ -112,14 +133,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const shootPasses = useMemo(
() => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [],
[selectedSpacecraftId, target, window.fromMs, window.toMs]
);
const stationOptions = useMemo(
() => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan),
[selectedPlan, selectedSpacecraftId, stations, window]
);
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>();
const [routeAnchor, setRouteAnchor] = useState<SurveyRouteAnchor>("CENTERED");
const [surveyRoute, setSurveyRoute] = useState<SurveyRoute>();
const [routeLoading, setRouteLoading] = useState(false);
const [routeError, setRouteError] = useState<string>();
const visibilityTracks = useMemo(
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
@@ -127,8 +147,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
const mapScene = useMemo(
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo),
[selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo]
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
);
const shootTargets = useMemo(
() =>
@@ -146,6 +166,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
})),
[contextSpacecraftIds, platforms]
);
// Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны.
const dropCandidates = useMemo<DropCandidateSurvey[]>(() => {
if (!selectedZone) return [];
const zoneStart = localDateTimeMs(selectedZone.onStart.time);
return planModes
.filter((m): m is PlanSurveyMode => m.type === "SURVEY" && m.timeStart != null)
.map((m) => {
const startMs = localDateTimeMs(m.timeStart as string);
return {
id: m.id,
label: `Съёмка витка ${m.revolution}`,
startMs,
endMs: startMs + m.duration * 1000
};
})
.filter((c) => c.endMs < zoneStart)
.sort((a, b) => a.startMs - b.startMs);
}, [planModes, selectedZone]);
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
const addedCount = draft.works.filter((work) => work.isNew).length;
@@ -156,7 +195,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
setSelectedDownlink(undefined);
setSurveyRoute(undefined);
setRouteError(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
setRvaZones([]);
setMapInfoSelection(undefined);
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
@@ -190,6 +235,125 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
return () => controller.abort();
}, [window.fromMs, window.toMs]);
useEffect(() => {
let cancelled = false;
void fetchStations()
.then((items) => { if (!cancelled) setStationDtos(items); })
.catch(() => { if (!cancelled) setStationDtos([]); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !Number.isFinite(numericNorad)) {
setShootPasses([]);
setShootPassesError(noradId ? undefined : target ? "Не задан NORAD-идентификатор КА." : undefined);
setShootPassesLoading(false);
return;
}
let cancelled = false;
setShootPassesLoading(true);
setShootPassesError(undefined);
void fetchPointVisibility(
numericNorad,
{ id: target.id, lat: target.lat, lon: target.lon },
window.fromMs,
window.toMs
)
.then((params) => {
if (cancelled) return;
setShootPasses(
params.map(visibilityWindowFromParam).sort((a, b) => a.startMs - b.startMs)
);
})
.catch((error) => {
if (cancelled) return;
setShootPasses([]);
setShootPassesError(error instanceof Error ? error.message : "Не удалось получить параметры видимости.");
})
.finally(() => {
if (!cancelled) setShootPassesLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, target, noradId, window.fromMs, window.toMs]);
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) {
setSurveyRoute(undefined);
setRouteError(undefined);
setRouteLoading(false);
return;
}
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
let cancelled = false;
setRouteLoading(true);
setRouteError(undefined);
void buildSurveyRoute({
satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19),
durationSeconds,
roll: selectedShootPass.rollDeg ?? 0,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor
})
.then((route) => {
if (!cancelled) setSurveyRoute(route);
})
.catch((error) => {
if (cancelled) return;
setSurveyRoute(undefined);
setRouteError(error instanceof Error ? error.message : "Не удалось построить маршрут съёмки.");
})
.finally(() => {
if (!cancelled) setRouteLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, target, selectedShootPass, noradId, routeAnchor]);
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "downlink" || !Number.isFinite(numericNorad)) {
setRvaZones([]);
setRvaError(rightMode === "downlink" && !noradId ? "Не задан NORAD-идентификатор КА." : undefined);
setRvaLoading(false);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
return;
}
let cancelled = false;
setRvaLoading(true);
setRvaError(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs)
.then((zones) => {
if (!cancelled) setRvaZones(zones);
})
.catch((error) => {
if (cancelled) return;
setRvaZones([]);
setRvaError(error instanceof Error ? error.message : "Не удалось получить зоны радиовидимости.");
})
.finally(() => {
if (!cancelled) setRvaLoading(false);
});
return () => {
cancelled = true;
};
}, [rightMode, noradId, window.fromMs, window.toMs]);
useEffect(() => {
if (!selectedPlan) return;
const planId = selectedPlan.planId;
@@ -213,9 +377,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
changeDraft((current) => updateWork(current, workId, patch));
};
const addShootWork = () => {
const addShootWork = async () => {
if (!target || !selectedShootPass || !modeId) return;
// Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
const numericNorad = noradId ? Number(noradId) : NaN;
if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) {
const planId = selectedPlan.planId;
const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000);
setRouteLoading(true);
setRouteError(undefined);
try {
const saved = await saveSurveyRoute(planId, {
satelliteId: numericNorad,
time: new Date(momentMs).toISOString().slice(0, 19),
durationSeconds: routeDurationSeconds,
roll: selectedShootPass.rollDeg ?? 0,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: routeAnchor,
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
});
setSurveyRoute(saved);
} catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return;
} finally {
setRouteLoading(false);
}
}
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
const mode = modes.find((item) => item.id === modeId);
@@ -237,25 +433,63 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setSelectedShootPass(undefined);
};
const addDownlinkWork = () => {
if (!selectedDownlink) return;
const stationNameById = useCallback(
(stationId: number) =>
stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`,
[stationDtos]
);
const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000);
const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2);
const addDropWork = async () => {
if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return;
const numericNorad = noradId ? Number(noradId) : NaN;
if (!Number.isFinite(numericNorad)) {
setDropError("Не задан NORAD-идентификатор КА.");
return;
}
const planId = selectedPlan.planId;
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
setDropSaving(true);
setDropError(undefined);
try {
await saveDropRoute(planId, {
satelliteId: numericNorad,
station: String(selectedZone.stationId),
revolution: selectedZone.revolution,
// Зона — LocalDateTime без зоны (как вернула баллистика).
zoneStart: selectedZone.onStart.time.slice(0, 19),
zoneStop: selectedZone.onStop.time.slice(0, 19),
surveyModeIds: [...selectedDropSurveyIds],
// Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
});
} catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return;
} finally {
setDropSaving(false);
}
// Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне.
void fetchPlanModes(planId).then(setPlanModes).catch(() => {});
const stationNm = stationNameById(selectedZone.stationId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addDownlink(current, {
id,
startMs,
endMs: startMs + durationMs,
label: "Сброс на НКПОР",
station: selectedDownlink.station
startMs: zoneStartMsVal,
endMs: zoneStopMsVal,
label: `Сброс · ${stationNm}`,
station: { id: String(selectedZone.stationId), name: stationNm }
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setSelectedDownlink(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
};
const runCheck = () => {
@@ -302,6 +536,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
});
};
const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (rightMode === "shoot") return;
if (!selection) {
if (rightMode === "mapInfo") {
setRightMode("none");
setMapInfoSelection(undefined);
}
return;
}
setMapInfoSelection(selection);
setRightMode("mapInfo");
setSelectedWorkId(undefined);
}, [rightMode]);
const handleResizerDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
@@ -349,6 +597,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onSetRightMode={(mode) => {
setRightMode(mode);
setSelectedWorkId(undefined);
setMapInfoSelection(undefined);
setNotice(undefined);
}}
onUndo={() => setHistory((current) => undo(current))}
@@ -426,7 +675,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setModeId(DEFAULT_EDITOR_MODES[0].id);
setNotice(undefined);
}}
onObjectSelect={() => undefined}
onObjectSelect={handleMapObjectSelect}
/>
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
</div>
@@ -484,9 +733,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
<ShootPanel
target={target}
passes={shootPasses}
loading={shootPassesLoading}
error={shootPassesError}
selectedPass={selectedShootPass}
modeId={modeId}
modes={modes}
routeAnchor={routeAnchor}
routeLoading={routeLoading}
routeError={routeError}
route={surveyRoute}
onAnchorChange={setRouteAnchor}
onSelectPass={setSelectedShootPass}
onModeChange={setModeId}
onAdd={addShootWork}
@@ -502,14 +758,47 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
/>
)}
{rightMode === "downlink" && (
<DownlinkPanel
stationOptions={stationOptions}
selected={selectedDownlink}
onSelect={setSelectedDownlink}
onAdd={addDownlinkWork}
<DropPanel
zones={rvaZones}
loading={rvaLoading}
error={rvaError}
selectedZone={selectedZone}
onSelectZone={(zone) => {
setSelectedZone(zone);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
}}
stationName={stationNameById}
zoneStartMs={(zone) => localDateTimeMs(zone.onStart.time)}
zoneStopMs={(zone) => localDateTimeMs(zone.onStop.time)}
candidates={dropCandidates}
selectedSurveyIds={selectedDropSurveyIds}
onToggleSurvey={(id) =>
setSelectedDropSurveyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
})
}
saving={dropSaving}
saveError={dropError}
onAdd={addDropWork}
onClose={() => {
setRightMode("none");
setSelectedDownlink(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
}}
/>
)}
{rightMode === "mapInfo" && mapInfoSelection && (
<MapInfoPanel
selection={mapInfoSelection}
requestItems={requestItems}
onClose={() => {
setRightMode("none");
setMapInfoSelection(undefined);
}}
/>
)}
@@ -520,6 +809,21 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
);
}
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = Date.parse(param.time);
const half = SHOOT_WINDOW_MS / 2;
return {
startMs: timeMs - half,
endMs: timeMs + half,
quality: 0,
revolution: param.revolution,
rollDeg: param.gamma,
sunAngleDeg: param.sunAngle,
range: param.range,
revSign: param.revSign
};
}
function defaultEditorWindow(
selectedPlan: TguEditorTabProps["selectedPlan"],
appliedRange: TguEditorTabProps["appliedRange"]
@@ -542,30 +846,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
}
function buildStationOptions(
stations: EditorGroundStation[],
spacecraftId: string | undefined,
window: EditorTimelineWindow,
selectedPlan: TguEditorTabProps["selectedPlan"]
): DownlinkStationOption[] {
return stations.map((station) => {
const computedPasses =
station.lat !== undefined && station.lon !== undefined
? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
: [];
const fallbackPass = selectedPlan && selectedPlan.kppId === station.id
? [{
startMs: Math.max(window.fromMs, selectedPlan.startMs),
endMs: Math.min(window.toMs, selectedPlan.endMs),
quality: 0
}]
: [];
return {
station,
passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs)
};
});
/**
* LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi):
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
*/
function localDateTimeMs(s: string): number {
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
return Date.parse(hasZone ? s : `${s}Z`);
}
function buildVisibilityTracks(
@@ -609,11 +896,12 @@ function buildEditorMapScene(
spacecraftId: string | undefined,
flightLineData: FlightLinePoint[],
orbitPasses: OrbitPassInfo[],
stations: EditorGroundStation[],
stationDtos: StationDto[],
requestItems: RequestMapItem[],
planModes: PlanMode[],
fromIndex: number,
toIndex: number
toIndex: number,
surveyRoute?: SurveyRoute
): Tgu2DMapScene {
const visibleRevolutions = new Set(
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
@@ -689,16 +977,26 @@ function buildEditorMapScene(
}
}
const markers = stations.flatMap((station) => {
if (station.lat === undefined || station.lon === undefined) return [];
return [{
id: station.id,
layer: "stations" as const,
point: { lat: station.lat, lon: station.lon },
label: station.name,
zIndex: 10
}];
});
// Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана
// отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без
// дополнительной настройки слоёв.
if (surveyRoute?.contourWkt) {
const rings = parseWktPolygon(surveyRoute.contourWkt);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `surveyRoute:${zIndex}`,
name: `Маршрут съёмки (виток ${surveyRoute.revolution})`,
kind: "planWork",
layer: "planWorks",
points: ring,
status: "ISSUING",
zIndex: zIndex++
});
}
}
const markers = buildStationMarkers(stationDtos);
return { polygons, lines, markers };
}