Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
T
Дмитрий Соловьев 294b17757d pcp: время — сквозной naive LocalDateTime как локальное настенное время (без UTC)
Отказ от остаточной UTC-семантики: naive LocalDateTime трактуется как
локальное время единой зоны всех сервисов.

Backend (границы внешних систем):
- VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase: ZoneOffset.UTC →
  ZoneId.systemDefault() для проекции внешних Instant/ISO-с-Z в naive.
- тесты границ перевёрнуты на systemDefault (под Europe/Moscow ждут +3).
- чистка UTC-формулировок в DTO-комментариях, мёртвых ссылок на
  docs/standards/DATETIME_UTC.md.

Frontend (pcp-tgu-ops-ui):
- utils/utcTime.ts → utils/localTime.ts: parseLocal/formatLocal*/toLocalIso/
  toLocalInputValue с локальной семантикой (без дописывания Z и timeZone:UTC).
- замена getUTC*/Date.UTC/toISOString-сериализации на локальные аналоги.
- убраны пользовательские подписи «UTC» в редакторе/таймлайне.
- удалён utcTime.guard.test.ts (форсил обратный UTC-инвариант); тест-фикстуры
  сделаны зоно-независимыми. tsc + vitest зелёные под TZ=UTC и TZ=Europe/Moscow.

Включает также прочие незакоммиченные правки рабочего дерева.
2026-06-04 09:50:39 +03:00

1320 lines
57 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 { 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, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import {
buildSurveyRoute,
deleteMode,
saveDropRoute,
saveSurveyRoute,
validateMission,
type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { parseLocal, toLocalIso } from "../../utils/localTime";
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 { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
import { validationToConflicts } from "./editorValidation";
import {
addDownlink,
addShoot,
deleteWork,
duplicateWork,
mutate,
planModesToWorks,
redo,
seedDraft,
undo,
updateWork
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels";
import { MapPassSelector } from "./MapPassSelector";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
import { EditorToolbar } from "./EditorToolbar";
import type {
DraftHistory,
EditorGroundStation,
EditorMode,
EditorTarget,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorVisibilityWindow,
EditorWork,
EditorWorkPatch,
TguEditorTabProps
} from "./model/editorTypes";
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 DEFAULT_ROUTE_DURATION_MS = 30_000;
/** Предел крена (gamma) маршрута при ручной правке, градусы. */
const MAX_ROUTE_ROLL_DEG = 89;
/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */
const ROUTE_TIME_MARGIN_MS = 30 * 60_000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: true,
planWorks: true,
stations: true,
spacecraftMarkers: false,
requests: true
};
export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const centerRef = useRef<HTMLElement>(null);
// Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
// примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
const [mapRatio, setMapRatio] = useState(0.8);
const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
future: []
}));
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
const [selectedWorkId, setSelectedWorkId] = useState<string>();
const [target, setTarget] = useState<EditorTarget>();
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
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);
const [canceling, setCanceling] = useState(false);
const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
const [draftPlanId, setDraftPlanId] = useState<string>();
const [passFrom, setPassFrom] = useState(1);
const [passTo, setPassTo] = useState(999);
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
// Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
const draft = history.draft;
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
const mapRange = useMemo<PassRange>(() => {
const center = (window.fromMs + window.toMs) / 2;
return { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
}, [window.fromMs, window.toMs]);
const noradId = useMemo(() => {
if (!selectedSpacecraftId) return undefined;
const platform = platforms.find((p) => p.spacecraftId === selectedSpacecraftId);
return platform?.noradId ?? undefined;
}, [selectedSpacecraftId, platforms]);
const orbitPasses = useMemo<OrbitPassInfo[]>(() => {
if (flightLineData.length === 0) return [];
const groups = new Map<number, FlightLinePoint[]>();
for (const pt of flightLineData) {
const arr = groups.get(pt.revolution) ?? [];
arr.push(pt);
groups.set(pt.revolution, arr);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a - b)
.map(([revolution, pts], idx) => ({
index: idx + 1,
revolution,
startMs: parseLocal(pts[0].time),
endMs: parseLocal(pts[pts.length - 1].time),
}));
}, [flightLineData]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
// id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать
// только что сохранённое включение его plan-origin копией из перечитанных planModes.
const addedModeIds = useMemo(
() => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)),
[draft.works]
);
const displayWorks = useMemo<EditorWork[]>(
() => [
...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)),
...draft.works
],
[planWorks, draft.works, addedModeIds]
);
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>();
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное).
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
const [routeRoll, setRouteRoll] = useState<number>();
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]
);
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, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
);
// Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
const focusWorkOnMap = useCallback(
(workId: string) => {
const work = displayWorks.find((w) => w.id === workId);
const focus = workMapFocus(work, mapScene);
setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 }));
},
[displayWorks, mapScene]
);
const contextLanes = useMemo(
() =>
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
spacecraftId,
label: platformLabel(platforms, spacecraftId),
works: []
})),
[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]);
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
// локальные несохранённые твики без DRAFT применять нечего.
const dirty = Boolean(draftPlanId);
const addedCount = draft.works.filter((work) => work.isNew).length;
// Реальный состав плана берём из включений (plan modes), а не из черновика:
// черновик сидируется заглушкой, когда у плана нет встроенных works.
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);
useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
setSelectedWorkId(undefined);
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
setSurveyRoute(undefined);
setRouteError(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
setRvaZones([]);
setMapInfoSelection(undefined);
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
setPlanModes([]);
setPassFrom(1);
setPassTo(999);
setFlightLineData([]);
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
useEffect(() => {
if (!noradId) return;
let cancelled = false;
void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs)
.then((data) => { if (!cancelled) setFlightLineData(data); })
.catch(() => { if (!cancelled) setFlightLineData([]); });
return () => { cancelled = true; };
}, [noradId, mapRange]);
useEffect(() => {
const controller = new AbortController();
void fetchRequestsForMap({
endFrom: toLocalIso(window.fromMs),
beginTo: toLocalIso(window.toMs)
}).then((result) => {
if (!controller.signal.aborted) {
setRequestItems(result.items);
}
}).catch(() => {
// silently ignore — requests are supplementary
});
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]);
// При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента
// видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную.
useEffect(() => {
if (rightMode !== "shoot" || !selectedShootPass) {
setRouteWindow(undefined);
setRouteRoll(undefined);
return;
}
const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs;
const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2;
const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
setRouteWindow({ startMs: mid - half, endMs: mid + half });
// Крен — не точнее одного знака после запятой (как и при ручном вводе).
setRouteRoll(Math.round((selectedShootPass.rollDeg ?? 0) * 10) / 10);
}, [rightMode, selectedShootPass]);
useEffect(() => {
const numericNorad = noradId ? Number(noradId) : NaN;
if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) {
setSurveyRoute(undefined);
setRouteError(undefined);
setRouteLoading(false);
return;
}
const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
let cancelled = false;
setRouteLoading(true);
setRouteError(undefined);
void buildSurveyRoute({
satelliteId: numericNorad,
// Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
time: toLocalIso(routeWindow.startMs),
durationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: "FORWARD"
})
.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, routeWindow, routeRoll]);
// Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс».
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]);
// Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки),
// редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace).
// createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный.
const createdImplicitlyRef = useRef(false);
useEffect(() => {
setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined);
createdImplicitlyRef.current = false;
setServiceConflicts({});
}, [selectedPlan]);
// Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик,
// не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409).
const draftPlanIdRef = useRef<string | undefined>(undefined);
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
useEffect(() => () => {
const stale = draftPlanIdRef.current;
// Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем.
if (stale && createdImplicitlyRef.current) {
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
void abandonDraft(stale).catch(() => {});
}
}, [selectedPlan]);
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
useEffect(() => {
const missionId = draftPlanId ?? selectedPlan?.planId;
if (!missionId) return;
let cancelled = false;
void fetchPlanModes(missionId).then((modes) => {
if (!cancelled) setPlanModes(modes);
}).catch(() => {
// silently ignore — plan modes are supplementary display data
});
return () => { cancelled = true; };
}, [selectedPlan, draftPlanId]);
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
setHistory((current) => mutate(current, transform));
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
};
const onChangeWork = (workId: string, patch: EditorWorkPatch) => {
changeDraft((current) => updateWork(current, workId, patch));
};
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
// Выбран уже существующий черновик — правим его напрямую, новый клон не создаём.
if (selectedPlan.status === "DRAFT") {
setDraftPlanId(selectedPlan.planId);
return selectedPlan.planId;
}
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
createdImplicitlyRef.current = true;
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
}, [draftPlanId, selectedPlan]);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => {
try {
const validation = await validateMission(missionId);
setServiceConflicts(validationToConflicts(validation, works));
} catch {
// валидация вспомогательна — молча игнорируем сбой
}
}, []);
// После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем.
const refreshDraftModes = useCallback(async (missionId: string) => {
const modes = await fetchPlanModes(missionId);
setPlanModes(modes);
void revalidate(missionId, planModesToWorks(modes));
}, [revalidate]);
const addShootWork = async () => {
if (!target || !selectedShootPass || !modeId) return;
// Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
const numericNorad = noradId ? Number(noradId) : NaN;
// Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch).
let savedRoute: SurveyRoute | undefined = surveyRoute;
if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
setRouteLoading(true);
setRouteError(undefined);
try {
// Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план.
const draftId = await ensureDraft();
savedRoute = await saveSurveyRoute(draftId, {
satelliteId: numericNorad,
time: toLocalIso(routeWindow.startMs),
durationSeconds: routeDurationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: "FORWARD",
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
});
setSurveyRoute(savedRoute);
void refreshDraftModes(draftId);
} catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return;
} finally {
setRouteLoading(false);
}
}
// Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода).
const startMs = routeWindow?.startMs ?? selectedShootPass.startMs;
const endMs = routeWindow?.endMs ?? selectedShootPass.endMs;
const mode = modes.find((item) => item.id === modeId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addShoot(current, {
id,
startMs,
endMs,
label: mode?.label ?? "Съёмка",
modeId,
target,
// id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика.
planModeId: savedRoute?.surveyModeId,
revolution: savedRoute?.revolution ?? selectedShootPass.revolution,
rollDeg: savedRoute?.roll ?? routeRoll,
captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
centerLat: savedRoute?.lat,
centerLon: savedRoute?.longitude
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setTarget(undefined);
setSelectedShootPass(undefined);
};
const stationNameById = useCallback(
(stationId: number) =>
stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`,
[stationDtos]
);
// Метка съёмки по id режима — для перечня сбрасываемых маршрутов в инспекторе сброса.
const surveyModeLabel = useCallback(
(surveyModeId: number) => {
const mode = planModes.find(
(m): m is PlanSurveyMode => m.type === "SURVEY" && m.id === surveyModeId
);
return mode ? `Съёмка · виток ${mode.revolution}` : `Маршрут #${surveyModeId}`;
},
[planModes]
);
const addDropWork = async () => {
if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return;
const numericNorad = noradId ? Number(noradId) : NaN;
if (!Number.isFinite(numericNorad)) {
setDropError("Не задан NORAD-идентификатор КА.");
return;
}
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
setDropSaving(true);
setDropError(undefined);
let savedDropId: number | undefined;
try {
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
const draftId = await ensureDraft();
const savedDrop = await saveDropRoute(draftId, {
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: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
});
savedDropId = savedDrop.id;
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
void refreshDraftModes(draftId);
} catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return;
} finally {
setDropSaving(false);
}
const stationNm = stationNameById(selectedZone.stationId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addDownlink(current, {
id,
startMs: zoneStartMsVal,
endMs: zoneStopMsVal,
label: `Сброс · ${stationNm}`,
station: { id: String(selectedZone.stationId), name: stationNm },
planModeId: savedDropId
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
};
// «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное
// превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было).
const runCheck = async () => {
setChecking(true);
setApplyError(undefined);
try {
if (draftPlanId) {
const validation = await validateMission(draftPlanId);
setServiceConflicts(validationToConflicts(validation, planWorks));
setNotice("Проверка выполнена на сервере (валидация черновика).");
} else {
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
setNotice("Проверка видимости выполнена для текущего черновика.");
}
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку.");
} finally {
setChecking(false);
}
};
// «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под
// оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка).
const applyDraft = async () => {
if (!draftPlanId) {
setApplyError("Нет правок для применения — черновик ещё не создан.");
setNotice(undefined);
return;
}
setApplying(true);
setApplyError(undefined);
setNotice(undefined);
try {
const result = await promoteDraft(draftPlanId);
setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`);
setDraftPlanId(undefined);
setServiceConflicts({});
setHistory((current) => ({ ...current, past: [], future: [] }));
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
} finally {
setApplying(false);
}
};
// «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает
// редактор к живому плану (read-only) и сбрасывает локальные правки.
const cancelDraft = async () => {
if (!draftPlanId) return;
setCanceling(true);
setApplyError(undefined);
setNotice(undefined);
try {
await abandonDraft(draftPlanId);
setDraftPlanId(undefined);
setServiceConflicts({});
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setSelectedWorkId(undefined);
setRightMode("none");
setNotice("Черновик отменён — правки отброшены.");
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик.");
} finally {
setCanceling(false);
}
};
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
// (planModeId нет) — только локально.
const handleDeleteWork = async (workId: string) => {
const work = draft.works.find((item) => item.id === workId);
if (work?.planModeId != null && draftPlanId) {
try {
await deleteMode(work.planModeId);
await refreshDraftModes(draftPlanId);
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение.");
return;
}
}
changeDraft((current) => deleteWork(current, workId));
setSelectedWorkId(undefined);
setRightMode("none");
};
const zoom = (factor: number) => {
setWindow((current) => {
const center = (current.fromMs + current.toMs) / 2;
const half = ((current.toMs - current.fromMs) / 2) * factor;
return { fromMs: center - half, toMs: center + half };
});
};
const pan = (factor: number) => {
setWindow((current) => {
const delta = (current.toMs - current.fromMs) * factor;
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
});
};
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;
const startRatio = mapRatio;
const onMove = (ev: MouseEvent) => {
const el = centerRef.current;
if (!el) return;
const dy = ev.clientY - startY;
setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight)));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [mapRatio]);
if (!selectedSpacecraftId && !selectedPlan) {
return (
<main className="tgu-editor-workspace">
<div className="tgu-empty">Выберите КА или план, чтобы открыть редактор включений.</div>
</main>
);
}
return (
<main className="tgu-editor-workspace">
<EditorToolbar
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
canUndo={history.past.length > 0}
canRedo={history.future.length > 0}
checking={checking}
conflictCount={conflictCount(conflicts)}
addedCount={addedCount}
dirty={dirty}
applying={applying}
canceling={canceling}
windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode}
onSetRightMode={(mode) => {
setRightMode(mode);
setSelectedWorkId(undefined);
setMapInfoSelection(undefined);
setNotice(undefined);
}}
onUndo={() => setHistory((current) => undo(current))}
onRedo={() => setHistory((current) => redo(current))}
onCheck={runCheck}
onApply={applyDraft}
onCancel={cancelDraft}
onZoom={zoom}
onPan={pan}
/>
<div className="tgu-editor-notices">
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
</div>
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
<EditorRail
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
surveyCount={planSurveyCount}
downlinkCount={planDownlinkCount}
conflictCount={conflictCount(conflicts)}
contextSpacecraftIds={contextSpacecraftIds}
onToggleContext={(spacecraftId) => {
setContextSpacecraftIds((current) => {
const next = new Set(current);
if (next.has(spacecraftId)) {
next.delete(spacecraftId);
} else {
next.add(spacecraftId);
}
return next;
});
}}
/>
<section
className="tgu-editor-center"
ref={centerRef}
style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }}
>
<div className="tgu-editor-map-stage">
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
{orbitPasses.length > 0 && (
<MapPassSelector
passes={orbitPasses}
fromIndex={effectiveFrom}
toIndex={effectiveTo}
onFromChange={setPassFrom}
onToChange={setPassTo}
/>
)}
<Tgu2DMapView
scene={mapScene}
layers={EDITOR_MAP_LAYERS}
redrawToken={0}
pickMode={rightMode === "shoot"}
pickTarget={target ?? undefined}
onPickPoint={(point) => {
// Опорная точка прицеливания — всегда одна: каждый клик заменяет
// предыдущее перекрестие, цели не накапливаются.
targetCounterRef.current += 1;
setTarget({
id: `target-${targetCounterRef.current}`,
name: "Опорная точка прицеливания",
lat: point.lat,
lon: point.lon
});
setSelectedShootPass(undefined);
setModeId(DEFAULT_EDITOR_MODES[0].id);
setNotice(undefined);
}}
onObjectSelect={handleMapObjectSelect}
focusPoint={mapFocus.point}
focusToken={mapFocus.token}
selectedObjectId={mapFocus.objectId}
/>
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
</div>
<EditorTimeline
activeLabel={activeLabel}
works={displayWorks}
selectedWorkId={selectedWorkId}
window={window}
planStartMs={selectedPlan?.startMs}
planEndMs={selectedPlan?.endMs}
conflicts={conflicts}
contextLanes={contextLanes}
visibilityTracks={visibilityTracks}
onSelectWork={(workId) => {
setSelectedWorkId(workId);
setRightMode("inspect");
setNotice(undefined);
focusWorkOnMap(workId);
}}
onClearSelection={() => {
if (rightMode === "inspect") {
setSelectedWorkId(undefined);
setRightMode("none");
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
}
}}
/>
</section>
{rightMode !== "none" && (
<aside className="tgu-editor-side-panel">
{rightMode === "inspect" && selectedWork && (
<WorkInspector
work={selectedWork}
modes={modes}
stations={stations}
spacecraftLabel={activeLabel}
surveyModeLabel={surveyModeLabel}
conflicts={conflicts[selectedWork.id] ?? []}
readOnly={selectedWork.origin === "plan"}
onUpdate={onChangeWork}
onDelete={(workId) => { void handleDeleteWork(workId); }}
onDuplicate={(workId) => {
const id = nextWorkId(idCounterRef);
changeDraft((current) => duplicateWork(current, workId, id));
setSelectedWorkId(id);
}}
onClose={() => {
setSelectedWorkId(undefined);
setRightMode("none");
}}
/>
)}
{rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
{rightMode === "shoot" && (
<ShootPanel
target={target}
passes={shootPasses}
loading={shootPassesLoading}
error={shootPassesError}
selectedPass={selectedShootPass}
modeId={modeId}
modes={modes}
routeWindow={routeWindow}
routeRoll={routeRoll}
passStartMs={selectedShootPass?.startMs}
passEndMs={selectedShootPass?.endMs}
routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined}
routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined}
routeLoading={routeLoading}
routeError={routeError}
route={surveyRoute}
onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
onRouteShift={(deltaMs) =>
setRouteWindow((current) => {
if (!current || selectedShootPass == null) return current;
// Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости.
const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS;
const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS;
let shift = deltaMs;
if (current.endMs + shift > maxMs) shift = maxMs - current.endMs;
if (current.startMs + shift < minMs) shift = minMs - current.startMs;
return { startMs: current.startMs + shift, endMs: current.endMs + shift };
})
}
onRouteRollChange={(deltaDeg) =>
setRouteRoll((current) =>
Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg))
)
}
onRouteRollSet={(deg) =>
setRouteRoll((current) =>
Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current
)
}
onSelectPass={setSelectedShootPass}
onModeChange={setModeId}
onAdd={addShootWork}
onClose={() => {
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
}}
onClearTarget={() => {
setTarget(undefined);
setSelectedShootPass(undefined);
}}
/>
)}
{rightMode === "downlink" && (
<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");
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
}}
/>
)}
{rightMode === "mapInfo" && mapInfoSelection && (
<MapInfoPanel
selection={mapInfoSelection}
requestItems={requestItems}
onClose={() => {
setRightMode("none");
setMapInfoSelection(undefined);
}}
/>
)}
</aside>
)}
</div>
</main>
);
}
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = parseLocal(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"]
): EditorTimelineWindow {
if (selectedPlan) {
return {
fromMs: selectedPlan.startMs - 3 * HOUR_MS,
toMs: selectedPlan.endMs + 3 * HOUR_MS
};
}
return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs };
}
function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] {
if (!selectedPlan?.kppId) {
return [];
}
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
}
/**
* LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time`
* в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок — с offset (его учитываем);
* приводим к одной шкале в локальной зоне браузера.
*/
function localDateTimeMs(s: string): number {
return parseLocal(s);
}
function buildVisibilityTracks(
target: EditorTarget | undefined,
shootPasses: EditorVisibilityWindow[],
selectedWork: DraftHistory["draft"]["works"][number] | undefined,
stations: EditorGroundStation[],
spacecraftId: string | undefined,
window: EditorTimelineWindow
): EditorVisibilityTrack[] {
const tracks: EditorVisibilityTrack[] = [];
if (target) {
tracks.push({
id: "target",
label: target.name,
kind: "target",
color: "var(--now)",
windows: shootPasses
});
}
if (selectedWork?.kind === "downlink" && selectedWork.stationId) {
const station = stations.find((item) => item.id === selectedWork.stationId);
if (station?.lat !== undefined && station.lon !== undefined) {
tracks.push({
id: "station",
label: station.name,
kind: "station",
color: "var(--accent)",
windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
});
}
}
return tracks;
}
const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000;
function buildEditorMapScene(
spacecraftId: string | undefined,
flightLineData: FlightLinePoint[],
orbitPasses: OrbitPassInfo[],
stationDtos: StationDto[],
requestItems: RequestMapItem[],
planModes: PlanMode[],
fromIndex: number,
toIndex: number,
surveyRoute?: SurveyRoute
): Tgu2DMapScene {
const visibleRevolutions = new Set(
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
);
const byRevolution = new Map<number, FlightLinePoint[]>();
for (const pt of flightLineData) {
if (!visibleRevolutions.has(pt.revolution)) continue;
const arr = byRevolution.get(pt.revolution) ?? [];
arr.push(pt);
byRevolution.set(pt.revolution, arr);
}
const lines: MapLine[] = [];
const polygons: MapPolygon[] = [];
let zIndex = 0;
for (const [revolution, pts] of byRevolution) {
const passIdx = orbitPasses.find((p) => p.revolution === revolution)?.index ?? 0;
lines.push({
id: `${spacecraftId}:track:${revolution}`,
layer: "tracks",
points: pts.map((p) => ({ lat: p.lat, lon: p.long })),
spacecraftId,
passIndex: passIdx,
zIndex: zIndex++
});
const leftEdge = pts.map((p) => ({ lat: p.latLeft, lon: p.longLeft }));
const rightEdge = [...pts].reverse().map((p) => ({ lat: p.latRight, lon: p.longRight }));
polygons.push({
id: `${spacecraftId}:swath:${revolution}`,
name: `Полоса обзора витка ${revolution}`,
kind: "swath",
layer: "swath",
points: [...leftEdge, ...rightEdge],
zIndex: zIndex++
});
}
for (const mode of planModes) {
if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
const rings = parseWktPolygon(mode.contourWkt);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `planWork:${mode.id}:${zIndex}`,
name: `Съёмка витка ${mode.revolution}`,
kind: "planWork",
layer: "planWorks",
points: ring,
status: "PLANNED",
modeId: mode.id,
zIndex: zIndex++
});
}
}
// Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса
// [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс.
for (const mode of planModes) {
if (mode.type !== "DROP" || mode.timeStart == null) continue;
const startMs = localDateTimeMs(mode.timeStart);
const endMs = startMs + mode.duration * 1000;
const pts = flightLineData
.map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long }))
.filter((p) => p.t >= startMs && p.t <= endMs)
.sort((a, b) => a.t - b.t)
.map((p) => ({ lat: p.lat, lon: p.lon }));
if (pts.length < 2) continue;
lines.push({
id: `drop:${mode.id}`,
layer: "tracks",
kind: "drop",
modeId: mode.id,
points: pts,
zIndex: zIndex++
});
}
for (const item of requestItems) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}:${zIndex}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
surveyType: item.surveyType,
zIndex: zIndex++
});
}
}
// Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана
// отдельным цветом (статус 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 };
}
function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string {
if (!spacecraftId) {
return "КА не выбран";
}
const platform = platforms.find((item) => item.spacecraftId === spacecraftId);
return platform?.name ?? platform?.noradId ?? spacecraftId;
}
function nextWorkId(ref: MutableRefObject<number>): string {
ref.current += 1;
return `NW-${String(ref.current).padStart(4, "0")}`;
}
/**
* Объект карты, соответствующий включению, и точка для центрирования:
* - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура;
* - сброс → яркая линия сброса (drop) по modeId; точка — середина линии.
*/
function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } {
if (!work) return {};
if (work.kind === "shoot") {
const polygon =
work.planModeId != null
? scene.polygons.find((p) => p.modeId === work.planModeId)
: scene.polygons.find((p) => p.id.startsWith("surveyRoute:"));
const point: GeoPoint | undefined =
work.centerLat != null && work.centerLon != null
? { lat: work.centerLat, lon: work.centerLon }
: polygon
? polygonCentroid(polygon.points)
: undefined;
return { point, objectId: polygon?.id };
}
const line =
work.planModeId != null
? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId)
: undefined;
const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined;
return { point, objectId: line?.id };
}
function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined {
if (points.length === 0) return undefined;
let lat = 0;
let lon = 0;
for (const p of points) {
lat += p.lat;
lon += p.lon;
}
return { lat: lat / points.length, lon: lon / points.length };
}