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(null); // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно // примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель. const [mapRatio, setMapRatio] = useState(0.8); const [history, setHistory] = useState(() => ({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] })); const [window, setWindow] = useState(() => defaultEditorWindow(selectedPlan, appliedRange)); const [rightMode, setRightMode] = useState("none"); const [selectedWorkId, setSelectedWorkId] = useState(); const [target, setTarget] = useState(); const [selectedShootPass, setSelectedShootPass] = useState(); const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); const [rvaZones, setRvaZones] = useState([]); const [rvaLoading, setRvaLoading] = useState(false); const [rvaError, setRvaError] = useState(); const [selectedZone, setSelectedZone] = useState(); const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState>(new Set()); const [dropSaving, setDropSaving] = useState(false); const [dropError, setDropError] = useState(); const [mapInfoSelection, setMapInfoSelection] = useState(); const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); const [canceling, setCanceling] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); const [requestItems, setRequestItems] = useState([]); const [planModes, setPlanModes] = useState([]); // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). // Пока null — редактор показывает живой план read-only; после создания все правки и валидация // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. const [draftPlanId, setDraftPlanId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). 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(() => { 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(() => { if (flightLineData.length === 0) return []; const groups = new Map(); 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(() => 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( () => [ ...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([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesError, setShootPassesError] = useState(); // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное). const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>(); // Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную. const [routeRoll, setRouteRoll] = useState(); const [surveyRoute, setSurveyRoute] = useState(); const [routeLoading, setRouteLoading] = useState(false); const [routeError, setRouteError] = useState(); 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(() => { 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(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 => { 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 (
Выберите КА или план, чтобы открыть редактор включений.
); } return (
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} />
{notice &&
{notice}
} {applyError &&
{applyError}
}
{ setContextSpacecraftIds((current) => { const next = new Set(current); if (next.has(spacecraftId)) { next.delete(spacecraftId); } else { next.add(spacecraftId); } return next; }); }} />
{rightMode === "shoot" &&
Кликните точку съёмки на карте.
} {orbitPasses.length > 0 && ( )} { // Опорная точка прицеливания — всегда одна: каждый клик заменяет // предыдущее перекрестие, цели не накапливаются. 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} />
{ setSelectedWorkId(workId); setRightMode("inspect"); setNotice(undefined); focusWorkOnMap(workId); }} onClearSelection={() => { if (rightMode === "inspect") { setSelectedWorkId(undefined); setRightMode("none"); setMapFocus((prev) => ({ ...prev, objectId: undefined })); } }} />
{rightMode !== "none" && ( )}
); } 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(); 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): 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 }; }