);
@@ -803,38 +786,6 @@ function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
);
}
-function TimeStepper({
- label,
- value,
- min,
- max,
- onChange
-}: {
- label: string;
- value: number;
- min?: number;
- max?: number;
- onChange: (value: number) => void;
-}) {
- const change = (deltaMs: number) => {
- const next = Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, value + deltaMs));
- onChange(next);
- };
-
- return (
-
- );
-}
-
/**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения).
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
index 1998bc0..fd19361 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx
@@ -30,11 +30,9 @@ import {
addDownlink,
addShoot,
deleteWork,
- duplicateWork,
mutate,
planModesToWorks,
- seedDraft,
- updateWork
+ seedDraft
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
@@ -52,7 +50,6 @@ import type {
EditorVisibilityTrack,
EditorVisibilityWindow,
EditorWork,
- EditorWorkPatch,
TguEditorTabProps
} from "./model/editorTypes";
@@ -114,7 +111,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
);
const [rightMode, setRightMode] = useState("none");
const [selectedWorkId, setSelectedWorkId] = useState();
+ // id редактируемого включения: окно построения открыто для правки существующего маршрута, а не
+ // создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально).
+ const [editingWorkId, setEditingWorkId] = useState();
+ // Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке
+ // (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения.
+ const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null);
+ // Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим.
+ const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null);
const [target, setTarget] = useState();
+ // Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой
+ // «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»),
+ // и клики не сбрасывают параметры маршрута.
+ const [targetPickActive, setTargetPickActive] = useState(false);
const [selectedShootPass, setSelectedShootPass] = useState();
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
const [rvaZones, setRvaZones] = useState([]);
@@ -198,6 +207,23 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
+ // Редактируемое включение и id его режима в БД: на время правки прячем его и из сцены карты, и с
+ // таймлайна — иначе исходный контур виден одновременно с живым превью (карта 2, таймлайн 1).
+ const editingWork = useMemo(
+ () => (editingWorkId ? displayWorks.find((work) => work.id === editingWorkId) : undefined),
+ [editingWorkId, displayWorks]
+ );
+ const editingModeId = editingWork?.planModeId;
+ // Включения для таймлайна: при правке исключаем редактируемое (его место занимает превью на карте).
+ const timelineWorks = useMemo(
+ () => (editingWorkId ? displayWorks.filter((work) => work.id !== editingWorkId) : displayWorks),
+ [displayWorks, editingWorkId]
+ );
+ // Режимы для сцены карты: при правке исключаем контур редактируемого режима (показываем превью).
+ const sceneModes = useMemo(
+ () => (editingModeId != null ? planModes.filter((mode) => mode.id !== editingModeId) : planModes),
+ [planModes, editingModeId]
+ );
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const [shootPasses, setShootPasses] = useState([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
@@ -216,8 +242,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
const mapScene = useMemo(
- () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
- [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
+ () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute),
+ [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute]
);
// Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
const focusWorkOnMap = useCallback(
@@ -277,8 +303,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
setSelectedWorkId(undefined);
+ setEditingWorkId(undefined);
setRightMode("none");
setTarget(undefined);
+ setTargetPickActive(false);
setSelectedShootPass(undefined);
setSurveyRoute(undefined);
setRouteError(undefined);
@@ -386,6 +414,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRouteRoll(undefined);
return;
}
+ // Правка существующего маршрута: подставляем его окно/крен один раз, не центрируем по проходу.
+ const prefill = editShootPrefillRef.current;
+ if (prefill) {
+ editShootPrefillRef.current = null;
+ setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs });
+ setRouteRoll(Math.round(prefill.roll * 10) / 10);
+ return;
+ }
const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs;
const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2;
const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2;
@@ -449,8 +485,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
let cancelled = false;
setRvaLoading(true);
setRvaError(undefined);
- setSelectedZone(undefined);
- setSelectedDropSurveyIds(new Set());
+ // Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз.
+ const prefill = editDropPrefillRef.current;
+ editDropPrefillRef.current = null;
+ if (prefill) {
+ setSelectedZone(prefill.zone);
+ setSelectedDropSurveyIds(new Set(prefill.surveyIds));
+ } else {
+ setSelectedZone(undefined);
+ setSelectedDropSurveyIds(new Set());
+ }
void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs)
.then((zones) => {
if (!cancelled) setRvaZones(zones);
@@ -518,8 +562,57 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setApplyError(undefined);
};
- const onChangeWork = (workId: string, patch: EditorWorkPatch) => {
- changeDraft((current) => updateWork(current, workId, patch));
+ // «Редактировать» включение: открываем то же окно, что и при построении, предзаполнив его
+ // параметрами включения. Сохранение заменит исходный маршрут (см. addShootWork/addDropWork).
+ const beginEditWork = (workId: string) => {
+ const work = displayWorks.find((item) => item.id === workId);
+ if (!work) return;
+ setEditingWorkId(workId);
+ setSelectedWorkId(undefined);
+ setMapInfoSelection(undefined);
+ clearMapPick();
+ setNotice(undefined);
+ setApplyError(undefined);
+
+ if (work.kind === "shoot") {
+ const lat = work.targetLat ?? work.centerLat;
+ const lon = work.targetLon ?? work.centerLon;
+ setTarget(
+ lat != null && lon != null
+ ? { id: `edit-${workId}`, name: work.targetName ?? "Цель съёмки", lat, lon }
+ : undefined
+ );
+ // Окно/крен подставит эффект дефолтного окна по этому ref (иначе он бы их центрировал).
+ editShootPrefillRef.current = { startMs: work.startMs, endMs: work.endMs, roll: work.rollDeg ?? 0 };
+ // Синтетический «проход» задаёт границы окна маршрута (±30 мин) и виток для пересчёта маршрута.
+ setSelectedShootPass({
+ startMs: work.startMs,
+ endMs: work.endMs,
+ quality: 1,
+ revolution: work.revolution,
+ rollDeg: work.rollDeg
+ });
+ setModeId(work.modeId ?? DEFAULT_EDITOR_MODES[0].id);
+ // Правка начинается без прицеливания: точка уже есть, карта в режиме «рука».
+ setTargetPickActive(false);
+ setRightMode("shoot");
+ return;
+ }
+
+ // Сброс: синтезируем зону из включения (для отображения и сохранения) и предвыбираем съёмки.
+ const stationNum = Number(work.stationId);
+ const midMs = (work.startMs + work.endMs) / 2;
+ const zone: RvaZone = {
+ noradId: noradId ? Number(noradId) : 0,
+ stationId: Number.isFinite(stationNum) ? stationNum : 0,
+ revolution: work.revolution ?? 0,
+ onStart: { time: toLocalIso(work.startMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
+ onMaximum: { time: toLocalIso(midMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
+ onStop: { time: toLocalIso(work.endMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
+ duration: Math.max(1, (work.endMs - work.startMs) / 1000)
+ };
+ editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] };
+ setRightMode("downlink");
};
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
@@ -557,9 +650,38 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
void revalidate(missionId, planModesToWorks(modes));
}, [revalidate]);
+ // Удаляет из черновика маршрут редактируемого включения (замена при правке). Свежепостроенное или
+ // уже-в-черновике включение адресуется по planModeId; включение из живого плана после клонирования
+ // получает новый id в черновике — находим его по типу/витку/времени старта.
+ const deleteEditedModeFromDraft = async (
+ draftId: string,
+ draftExistedBefore: boolean,
+ work: EditorWork,
+ type: "SURVEY" | "DROP"
+ ) => {
+ if (work.planModeId != null && draftExistedBefore) {
+ await deleteMode(work.planModeId).catch(() => {});
+ return;
+ }
+ const draftModes = await fetchPlanModes(draftId);
+ const match = draftModes.find(
+ (m) =>
+ m.type === type &&
+ m.revolution === work.revolution &&
+ m.timeStart != null &&
+ Math.abs(parseLocal(m.timeStart) - work.startMs) < 2000
+ );
+ if (match) await deleteMode(match.id).catch(() => {});
+ };
+
const addShootWork = async () => {
if (!target || !selectedShootPass || !modeId) return;
+ // Правка существующего маршрута: его исходную версию удаляем при сохранении (замена).
+ const editId = editingWorkId;
+ const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined;
+ const draftExistedBefore = draftPlanId != null;
+
// Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
@@ -573,6 +695,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
try {
// Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план.
const draftId = await ensureDraft();
+ // Заменяем: сносим исходный маршрут редактируемого включения перед сохранением нового.
+ if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY");
savedRoute = await saveSurveyRoute(draftId, {
satelliteId: numericNorad,
time: toLocalIso(routeWindow.startMs),
@@ -602,7 +726,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
- addShoot(current, {
+ addShoot(editId ? deleteWork(current, editId) : current, {
id,
startMs,
endMs,
@@ -618,9 +742,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
centerLon: savedRoute?.longitude
})
);
+ setEditingWorkId(undefined);
setSelectedWorkId(id);
setRightMode("inspect");
setTarget(undefined);
+ setTargetPickActive(false);
setSelectedShootPass(undefined);
};
@@ -652,12 +778,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
+ // Правка существующего сброса: его исходную версию удаляем при сохранении (замена).
+ const editId = editingWorkId;
+ const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined;
+ const draftExistedBefore = draftPlanId != null;
+
setDropSaving(true);
setDropError(undefined);
let savedDropId: number | undefined;
try {
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
const draftId = await ensureDraft();
+ // Заменяем: сносим исходный сброс редактируемого включения перед сохранением нового.
+ if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "DROP");
const savedDrop = await saveDropRoute(draftId, {
satelliteId: numericNorad,
station: String(selectedZone.stationId),
@@ -683,7 +816,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const stationNm = stationNameById(selectedZone.stationId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
- addDownlink(current, {
+ addDownlink(editId ? deleteWork(current, editId) : current, {
id,
startMs: zoneStartMsVal,
endMs: zoneStopMsVal,
@@ -692,6 +825,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planModeId: savedDropId
})
);
+ setEditingWorkId(undefined);
setSelectedWorkId(id);
setRightMode("inspect");
setSelectedZone(undefined);
@@ -761,6 +895,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
await refreshDraftModes(draftId);
changeDraft((current) => ({ ...current, works: [] }));
setSelectedWorkId(undefined);
+ setEditingWorkId(undefined);
setRightMode("none");
setNotice("План полностью очищен.");
} catch (error) {
@@ -794,6 +929,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
changeDraft((current) => deleteWork(current, workId));
setSelectedWorkId(undefined);
+ if (editingWorkId === workId) setEditingWorkId(undefined);
setRightMode("none");
};
@@ -967,13 +1103,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
conflicts={conflicts[work.id] ?? []}
readOnly={work.origin === "plan"}
canDelete={draftPlanId != null}
- onUpdate={onChangeWork}
+ canEdit={!inPlaceEditBlockedReason}
+ onEdit={beginEditWork}
onDelete={(workId) => { void handleDeleteWork(workId); }}
- onDuplicate={(workId) => {
- const id = nextWorkId(idCounterRef);
- changeDraft((current) => duplicateWork(current, workId, id));
- setSelectedWorkId(id);
- }}
onClose={onClose}
/>
);
@@ -1018,6 +1150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onSetRightMode={(mode) => {
setRightMode(mode);
setSelectedWorkId(undefined);
+ setEditingWorkId(undefined);
+ // Новая съёмка начинается с прицеливания (точки ещё нет); прочие режимы — без перекрестия.
+ setTargetPickActive(mode === "shoot");
+ setTarget(undefined);
setMapInfoSelection(undefined);
clearMapPick();
setNotice(undefined);
@@ -1061,7 +1197,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }}
>
- {rightMode === "shoot" &&
Кликните точку съёмки на карте.
}
+ {rightMode === "shoot" && targetPickActive &&
Кликните точку съёмки на карте.
}
{orbitPasses.length > 0 ? (
{
// Опорная точка прицеливания — всегда одна: каждый клик заменяет
// предыдущее перекрестие, цели не накапливаются.
@@ -1099,6 +1235,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setSelectedShootPass(undefined);
setModeId(DEFAULT_EDITOR_MODES[0].id);
setNotice(undefined);
+ // Прицеливание НЕ гасим: пока не выбран проход, точку можно свободно переставлять
+ // повторными кликами по карте. «Рука» включится при выборе строки прохождения.
}}
onObjectSelect={handleMapObjectSelect}
focusPoint={mapFocus.point}
@@ -1109,7 +1247,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,