294b17757d
Отказ от остаточной 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. Включает также прочие незакоммиченные правки рабочего дерева.
1320 lines
57 KiB
TypeScript
1320 lines
57 KiB
TypeScript
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 };
|
||
}
|