|
|
|
@@ -5,7 +5,15 @@ 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, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi";
|
|
|
|
|
import {
|
|
|
|
|
buildSurveyRoute,
|
|
|
|
|
deleteMode,
|
|
|
|
|
saveDropRoute,
|
|
|
|
|
saveSurveyRoute,
|
|
|
|
|
validateMission,
|
|
|
|
|
type SurveyRoute
|
|
|
|
|
} from "../../api/missionPlanningApi";
|
|
|
|
|
import { createDraft, promoteDraft } from "../../api/tguPlanApi";
|
|
|
|
|
import { parseUtc } from "../../utils/utcTime";
|
|
|
|
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
|
|
|
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
|
|
|
@@ -13,7 +21,8 @@ import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
|
|
|
|
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
|
|
|
|
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
|
|
|
|
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
|
|
|
|
import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
|
|
|
|
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
|
|
|
|
import { validationToConflicts } from "./editorValidation";
|
|
|
|
|
import {
|
|
|
|
|
addDownlink,
|
|
|
|
|
addShoot,
|
|
|
|
@@ -107,6 +116,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
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[]>([]);
|
|
|
|
@@ -141,7 +154,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
|
|
|
|
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
|
|
|
|
|
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
|
|
|
|
|
const displayWorks = useMemo<EditorWork[]>(() => [...planWorks, ...draft.works], [planWorks, draft.works]);
|
|
|
|
|
// 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);
|
|
|
|
@@ -194,7 +219,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
.sort((a, b) => a.startMs - b.startMs);
|
|
|
|
|
}, [planModes, selectedZone]);
|
|
|
|
|
|
|
|
|
|
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
|
|
|
|
|
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
|
|
|
|
// локальные несохранённые твики без DRAFT применять нечего.
|
|
|
|
|
const dirty = Boolean(draftPlanId);
|
|
|
|
|
const addedCount = draft.works.filter((work) => work.isNew).length;
|
|
|
|
|
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
|
|
|
|
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
|
|
|
@@ -383,17 +410,24 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
};
|
|
|
|
|
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
|
|
|
|
|
|
|
|
|
// Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedPlan) return;
|
|
|
|
|
const planId = selectedPlan.planId;
|
|
|
|
|
setDraftPlanId(undefined);
|
|
|
|
|
setServiceConflicts({});
|
|
|
|
|
}, [selectedPlan]);
|
|
|
|
|
|
|
|
|
|
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const missionId = draftPlanId ?? selectedPlan?.planId;
|
|
|
|
|
if (!missionId) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
void fetchPlanModes(planId).then((modes) => {
|
|
|
|
|
void fetchPlanModes(missionId).then((modes) => {
|
|
|
|
|
if (!cancelled) setPlanModes(modes);
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
// silently ignore — plan modes are supplementary display data
|
|
|
|
|
});
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [selectedPlan]);
|
|
|
|
|
}, [selectedPlan, draftPlanId]);
|
|
|
|
|
|
|
|
|
|
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
|
|
|
|
|
setHistory((current) => mutate(current, transform));
|
|
|
|
@@ -406,6 +440,34 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
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("План не выбран — нечего редактировать.");
|
|
|
|
|
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
@@ -413,13 +475,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
// выбранного плана (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 planId = selectedPlan.planId;
|
|
|
|
|
const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
|
|
|
|
|
setRouteLoading(true);
|
|
|
|
|
setRouteError(undefined);
|
|
|
|
|
try {
|
|
|
|
|
const saved = await saveSurveyRoute(planId, {
|
|
|
|
|
// Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план.
|
|
|
|
|
const draftId = await ensureDraft();
|
|
|
|
|
savedRoute = await saveSurveyRoute(draftId, {
|
|
|
|
|
satelliteId: numericNorad,
|
|
|
|
|
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
|
|
|
|
|
durationSeconds: routeDurationSeconds,
|
|
|
|
@@ -431,7 +496,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
|
|
|
|
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
|
|
|
|
});
|
|
|
|
|
setSurveyRoute(saved);
|
|
|
|
|
setSurveyRoute(savedRoute);
|
|
|
|
|
void refreshDraftModes(draftId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
|
|
|
|
|
return;
|
|
|
|
@@ -454,11 +520,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
label: mode?.label ?? "Съёмка",
|
|
|
|
|
modeId,
|
|
|
|
|
target,
|
|
|
|
|
revolution: surveyRoute?.revolution ?? selectedShootPass.revolution,
|
|
|
|
|
rollDeg: surveyRoute?.roll ?? routeRoll,
|
|
|
|
|
captureAngleDeg: surveyRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
|
|
|
|
|
centerLat: surveyRoute?.lat,
|
|
|
|
|
centerLon: surveyRoute?.longitude
|
|
|
|
|
// 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);
|
|
|
|
@@ -492,14 +560,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const planId = selectedPlan.planId;
|
|
|
|
|
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
|
|
|
|
|
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
|
|
|
|
|
|
|
|
|
|
setDropSaving(true);
|
|
|
|
|
setDropError(undefined);
|
|
|
|
|
let savedDropId: number | undefined;
|
|
|
|
|
try {
|
|
|
|
|
await saveDropRoute(planId, {
|
|
|
|
|
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
|
|
|
|
|
const draftId = await ensureDraft();
|
|
|
|
|
const savedDrop = await saveDropRoute(draftId, {
|
|
|
|
|
satelliteId: numericNorad,
|
|
|
|
|
station: String(selectedZone.stationId),
|
|
|
|
|
revolution: selectedZone.revolution,
|
|
|
|
@@ -511,6 +581,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
|
|
|
|
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
|
|
|
|
});
|
|
|
|
|
savedDropId = savedDrop.id;
|
|
|
|
|
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
|
|
|
|
|
void refreshDraftModes(draftId);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
|
|
|
|
|
return;
|
|
|
|
@@ -518,8 +591,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
setDropSaving(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне.
|
|
|
|
|
void fetchPlanModes(planId).then(setPlanModes).catch(() => {});
|
|
|
|
|
const stationNm = stationNameById(selectedZone.stationId);
|
|
|
|
|
const id = nextWorkId(idCounterRef);
|
|
|
|
|
changeDraft((current) =>
|
|
|
|
@@ -528,7 +599,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
startMs: zoneStartMsVal,
|
|
|
|
|
endMs: zoneStopMsVal,
|
|
|
|
|
label: `Сброс · ${stationNm}`,
|
|
|
|
|
station: { id: String(selectedZone.stationId), name: stationNm }
|
|
|
|
|
station: { id: String(selectedZone.stationId), name: stationNm },
|
|
|
|
|
planModeId: savedDropId
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
setSelectedWorkId(id);
|
|
|
|
@@ -537,17 +609,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
setSelectedDropSurveyIds(new Set());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runCheck = () => {
|
|
|
|
|
// «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное
|
|
|
|
|
// превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было).
|
|
|
|
|
const runCheck = async () => {
|
|
|
|
|
setChecking(true);
|
|
|
|
|
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
|
|
|
|
|
setChecking(false);
|
|
|
|
|
setNotice("Проверка видимости выполнена для текущего черновика.");
|
|
|
|
|
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 () => {
|
|
|
|
|
const planId = selectedPlan?.planId ?? draft.planId;
|
|
|
|
|
if (!planId) {
|
|
|
|
|
setApplyError("Нельзя применить черновик: план не выбран.");
|
|
|
|
|
if (!draftPlanId) {
|
|
|
|
|
setApplyError("Нет правок для применения — черновик ещё не создан.");
|
|
|
|
|
setNotice(undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
@@ -556,8 +643,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
setApplyError(undefined);
|
|
|
|
|
setNotice(undefined);
|
|
|
|
|
try {
|
|
|
|
|
const result = await saveEditedPlan({ planId, draft });
|
|
|
|
|
setNotice(result.message);
|
|
|
|
|
const result = await promoteDraft(draftPlanId);
|
|
|
|
|
setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`);
|
|
|
|
|
setDraftPlanId(undefined);
|
|
|
|
|
setServiceConflicts({});
|
|
|
|
|
setHistory((current) => ({ ...current, past: [], future: [] }));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
|
|
|
|
@@ -566,6 +655,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Удаление включения: персистнутые режимы (с 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;
|
|
|
|
@@ -756,11 +864,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|
|
|
|
conflicts={conflicts[selectedWork.id] ?? []}
|
|
|
|
|
readOnly={selectedWork.origin === "plan"}
|
|
|
|
|
onUpdate={onChangeWork}
|
|
|
|
|
onDelete={(workId) => {
|
|
|
|
|
changeDraft((current) => deleteWork(current, workId));
|
|
|
|
|
setSelectedWorkId(undefined);
|
|
|
|
|
setRightMode("none");
|
|
|
|
|
}}
|
|
|
|
|
onDelete={(workId) => { void handleDeleteWork(workId); }}
|
|
|
|
|
onDuplicate={(workId) => {
|
|
|
|
|
const id = nextWorkId(idCounterRef);
|
|
|
|
|
changeDraft((current) => duplicateWork(current, workId, id));
|
|
|
|
|