pcp-tgu-ops-ui: правка включения через окно построения + обновление состава плана

Редактор: инспектор включения теперь только просмотр (время с десятыми долями
секунды), кнопки «Редактировать»→«Удалить». «Редактировать» открывает окно
построения, предзаполненное параметрами, и при сохранении заменяет маршрут.
Прицеливание опорной точки — явный режим: активно, пока не выбран проход.

Планы: состав включений выбранного плана перечитывается при возврате на вкладку
«Планы», иначе счётчик маршрутов оставался прежним после правок в редакторе.
This commit is contained in:
Дмитрий Соловьев
2026-06-05 16:13:33 +03:00
parent 995396da3b
commit ea207088bc
7 changed files with 264 additions and 181 deletions
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields";
@@ -39,9 +39,11 @@ type WorkInspectorProps = {
readOnly?: boolean;
/** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */
canDelete?: boolean;
onUpdate: (workId: string, patch: EditorWorkPatch) => void;
/** План редактируемый — показываем кнопку «Редактировать» (открывает окно построения). */
canEdit?: boolean;
/** Открыть включение в окне построения для правки параметров. */
onEdit: (workId: string) => void;
onDelete: (workId: string) => void;
onDuplicate: (workId: string) => void;
onClose: () => void;
};
@@ -66,6 +68,12 @@ type ShootPanelProps = {
routeLoading?: boolean;
routeError?: string;
route?: SurveyRoute;
/** Режим правки существующей съёмки — меняет заголовок и подпись кнопки. */
editing?: boolean;
/** Активен режим прицеливания: карта в режиме клика по точке (перекрестие), иначе — «рука». */
picking?: boolean;
/** Включить режим прицеливания (указать/сменить опорную точку одним кликом по карте). */
onPickTarget: () => void;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onRouteStartChange: (startMs: number) => void;
@@ -80,7 +88,6 @@ type ShootPanelProps = {
onModeChange: (modeId: string) => void;
onAdd: () => void;
onClose: () => void;
onClearTarget: () => void;
};
/** Минимальная длительность маршрута, мс (нижняя граница окна). */
@@ -119,6 +126,8 @@ type DropPanelProps = {
onToggleSurvey: (id: number) => void;
saving?: boolean;
saveError?: string;
/** Режим правки существующего сброса — меняет заголовок и подпись кнопки. */
editing?: boolean;
/** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */
addBlockedReason?: string;
onAdd: () => void;
@@ -134,9 +143,9 @@ export function WorkInspector({
conflicts,
readOnly,
canDelete,
onUpdate,
canEdit,
onEdit,
onDelete,
onDuplicate,
onClose
}: WorkInspectorProps) {
const color = kindColor(work.kind);
@@ -156,22 +165,14 @@ export function WorkInspector({
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Время</div>
{readOnly ? (
<dl className="tgu-map-request-details__grid">
<dt>Начало</dt>
<dd className="mono">{formatDateTime(work.startMs)}</dd>
<dd className="mono">{formatDateTimeSeconds(work.startMs)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(work.endMs)}</dd>
<dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd>
<dt>Длительность</dt>
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
</dl>
) : (
<>
<TimeStepper label="начало" value={work.startMs} max={work.endMs - 60_000} onChange={(value) => onUpdate(work.id, { startMs: value })} />
<TimeStepper label="конец" value={work.endMs} min={work.startMs + 60_000} onChange={(value) => onUpdate(work.id, { endMs: value })} />
<div className="tgu-editor-panel__hint">длительность: {formatDuration(work.endMs - work.startMs)}</div>
</>
)}
</section>
<section className="tgu-editor-panel__section">
@@ -229,55 +230,23 @@ export function WorkInspector({
{work.kind === "shoot" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Режим аппаратуры</div>
{readOnly ? (
<div className="tgu-editor-panel__hint">{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}</div>
) : (
<div className="tgu-editor-chip-row">
{modes.map((mode) => (
<button
className={work.modeId === mode.id ? "is-selected" : ""}
key={mode.id}
type="button"
onClick={() => onUpdate(work.id, { modeId: mode.id, label: mode.label })}
>
{mode.label}
</button>
))}
</div>
)}
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
</section>
)}
{work.kind === "downlink" && !readOnly && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Станция приёма</div>
<select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}>
{stations.map((station) => (
<option key={station.id} value={station.id}>
{station.name}
</option>
))}
</select>
</section>
)}
{!readOnly && (
{(canEdit || !readOnly || canDelete) && (
<div className="tgu-editor-panel__actions">
<button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}>
Дублировать
{canEdit && (
<button className="tgu-button" type="button" onClick={() => onEdit(work.id)}>
Редактировать
</button>
)}
{(!readOnly || canDelete) && (
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
Удалить
</button>
</div>
)}
{readOnly && canDelete && (
<div className="tgu-editor-panel__actions">
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
{work.kind === "downlink" ? "Удалить сброс" : "Удалить маршрут съёмки"}
</button>
</div>
)}
@@ -303,6 +272,9 @@ export function ShootPanel({
routeLoading,
routeError,
route,
editing,
picking,
onPickTarget,
addBlockedReason,
onRouteStartChange,
onRouteEndChange,
@@ -312,25 +284,23 @@ export function ShootPanel({
onSelectPass,
onModeChange,
onAdd,
onClose,
onClearTarget
onClose
}: ShootPanelProps) {
return (
<div className="tgu-editor-panel">
<PanelHead title="Новая съёмка" onClose={onClose} />
<PanelHead title={editing ? "Редактирование съёмки" : "Новая съёмка"} onClose={onClose} />
<section className="tgu-editor-panel__section">
<div className="tgu-editor-panel__section-head">
<div className="tgu-editor-eyebrow">1 · опорная точка прицеливания</div>
{target && (
<button className="tgu-link-button" type="button" onClick={onClearTarget}>
сбросить
{target && !picking && (
<button className="tgu-link-button" type="button" onClick={onPickTarget}>
сменить
</button>
)}
</div>
{!target ? (
<div className="tgu-editor-panel__placeholder">Кликните точку на карте слева, чтобы задать опорную точку прицеливания.</div>
) : (
{target ? (
<>
<div className="tgu-editor-target">
<i />
<span>{target.name}</span>
@@ -338,6 +308,18 @@ export function ShootPanel({
{target.lat.toFixed(2)}, {target.lon.toFixed(2)}
</small>
</div>
{picking && (
<div className="tgu-editor-panel__hint">
Кликайте по карте, чтобы переставить точку. Выберите проход ниже, когда точка верна.
</div>
)}
</>
) : picking ? (
<div className="tgu-editor-panel__placeholder">Кликните точку на карте слева, чтобы задать опорную точку прицеливания.</div>
) : (
<button className="tgu-button" type="button" onClick={onPickTarget}>
Указать точку на карте
</button>
)}
</section>
@@ -461,7 +443,7 @@ export function ShootPanel({
onClick={onAdd}
disabled={!target || !selectedPass || !modeId || !!addBlockedReason}
>
+ Добавить съёмку в план
{editing ? "Сохранить съёмку" : "+ Добавить съёмку в план"}
</button>
</div>
);
@@ -523,6 +505,7 @@ export function DropPanel({
onToggleSurvey,
saving,
saveError,
editing,
addBlockedReason,
onAdd,
onClose
@@ -531,7 +514,7 @@ export function DropPanel({
return (
<div className="tgu-editor-panel">
<PanelHead title="Новый сброс по ЗРВ" onClose={onClose} />
<PanelHead title={editing ? "Редактирование сброса" : "Новый сброс по ЗРВ"} onClose={onClose} />
<div className="tgu-editor-panel__hint">
Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны.
</div>
@@ -601,7 +584,7 @@ export function DropPanel({
{saveError && <div className="tgu-alert tgu-alert--danger">{saveError}</div>}
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!canAdd}>
{saving ? "Сохранение…" : "+ Добавить сброс в план"}
{saving ? "Сохранение…" : editing ? "Сохранить сброс" : "+ Добавить сброс в план"}
</button>
</div>
);
@@ -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 (
<label className="tgu-editor-time-stepper">
<span>{label}</span>
<div>
<button type="button" onClick={() => change(-3_600_000)}>ч</button>
<button type="button" onClick={() => change(-5 * 60_000)}>5м</button>
<output>{formatDateTime(value)}</output>
<button type="button" onClick={() => change(5 * 60_000)}>+5м</button>
<button type="button" onClick={() => change(3_600_000)}>+ч</button>
</div>
</label>
);
}
/**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения).
@@ -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<EditorRightMode>("none");
const [selectedWorkId, setSelectedWorkId] = useState<string>();
// id редактируемого включения: окно построения открыто для правки существующего маршрута, а не
// создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально).
const [editingWorkId, setEditingWorkId] = useState<string>();
// Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке
// (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения.
const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null);
// Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим.
const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null);
const [target, setTarget] = useState<EditorTarget>();
// Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой
// «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»),
// и клики не сбрасывают параметры маршрута.
const [targetPickActive, setTargetPickActive] = useState(false);
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
const [rvaZones, setRvaZones] = useState<RvaZone[]>([]);
@@ -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<EditorVisibilityWindow[]>([]);
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);
// Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз.
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)` }}
>
<div className="tgu-editor-map-stage">
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
{rightMode === "shoot" && targetPickActive && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
{orbitPasses.length > 0 ? (
<MapPassSelector
passes={orbitPasses}
@@ -1084,8 +1220,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
redrawToken={0}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
pickMode={rightMode === "shoot"}
pickTarget={target ?? undefined}
pickMode={rightMode === "shoot" && targetPickActive}
pickTarget={targetPickActive ? target ?? undefined : undefined}
onPickPoint={(point) => {
// Опорная точка прицеливания — всегда одна: каждый клик заменяет
// предыдущее перекрестие, цели не накапливаются.
@@ -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,
</div>
<EditorTimeline
activeLabel={activeLabel}
works={displayWorks}
works={timelineWorks}
selectedWorkId={selectedWorkId}
window={window}
planStartMs={selectedPlan?.startMs}
@@ -1161,6 +1299,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
routeLoading={routeLoading}
routeError={routeError}
route={surveyRoute}
editing={editingWorkId != null}
picking={targetPickActive}
onPickTarget={() => setTargetPickActive(true)}
addBlockedReason={inPlaceEditBlockedReason}
onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))}
onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))}
@@ -1186,16 +1327,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current
)
}
onSelectPass={setSelectedShootPass}
onSelectPass={(pass) => {
setSelectedShootPass(pass);
// Проход выбран — точка зафиксирована: выходим из прицеливания, карта снова «рука».
if (pass) setTargetPickActive(false);
}}
onModeChange={setModeId}
onAdd={addShootWork}
onClose={() => {
setRightMode("none");
setEditingWorkId(undefined);
setTarget(undefined);
setSelectedShootPass(undefined);
}}
onClearTarget={() => {
setTarget(undefined);
setTargetPickActive(false);
setSelectedShootPass(undefined);
}}
/>
@@ -1226,10 +1369,12 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
saving={dropSaving}
saveError={dropError}
editing={editingWorkId != null}
addBlockedReason={inPlaceEditBlockedReason}
onAdd={addDropWork}
onClose={() => {
setRightMode("none");
setEditingWorkId(undefined);
setSelectedZone(undefined);
setSelectedDropSurveyIds(new Set());
setDropError(undefined);
@@ -4,7 +4,6 @@ import {
addDownlink,
addShoot,
deleteWork,
duplicateWork,
mutate,
planModesToWorks,
redo,
@@ -155,7 +154,7 @@ describe("editorDraft", () => {
expect(redone.future).toHaveLength(0);
});
it("updates, duplicates and deletes works without generating ids internally", () => {
it("updates and deletes works without generating ids internally", () => {
const draft = addDownlink(seedDraft(plan), {
id: "downlink-1",
label: "Сброс на НКПОР",
@@ -164,17 +163,9 @@ describe("editorDraft", () => {
station: { id: "KPP-1", name: "KPP-1" }
});
const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 });
const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000);
const deleted = deleteWork(duplicated, "downlink-1");
const deleted = deleteWork(updated, "downlink-1");
expect(updated.works[0].label).toBe("Сеанс");
expect(duplicated.works[1]).toMatchObject({
id: "copy-1",
startMs: updated.works[0].startMs + 120_000,
endMs: updated.works[0].endMs + 120_000,
origin: "added",
isNew: true
});
expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]);
expect(deleted.works.map((work) => work.id)).toEqual([]);
});
});
@@ -12,7 +12,6 @@ import type {
EditorWorkPatch
} from "./model/editorTypes";
const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000;
const DEFAULT_HISTORY_LIMIT = 50;
export type AddShootInput = {
@@ -198,29 +197,6 @@ export function deleteWork(draft: Draft, workId: string): Draft {
};
}
export function duplicateWork(
draft: Draft,
workId: string,
newId: string,
offsetMs = DEFAULT_DUPLICATE_OFFSET_MS
): Draft {
const source = draft.works.find((work) => work.id === workId);
if (!source) {
return draft;
}
const duplicated: EditorWork = {
...source,
id: newId,
startMs: source.startMs + offsetMs,
endMs: source.endMs + offsetMs,
origin: "added",
isNew: true
};
return { ...draft, works: [...draft.works, duplicated] };
}
function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] {
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
if (!Array.isArray(rawWorks)) {
@@ -48,6 +48,18 @@ export function formatDateTime(ms: number): string {
return `${formatDate(ms)} ${formatTime(ms)}`;
}
/** Время с секундами и одной десятой долей: ЧЧ:ММ:СС.с (локальное). */
export function formatTimeSeconds(ms: number): string {
const date = new Date(ms);
const tenths = Math.floor((((ms % 1000) + 1000) % 1000) / 100);
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${tenths}`;
}
/** Дата и время с десятыми долями секунды: «дд мес ЧЧ:ММ:СС.с» (локальное). */
export function formatDateTimeSeconds(ms: number): string {
return `${formatDate(ms)} ${formatTimeSeconds(ms)}`;
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
if (totalSeconds < 60) {
@@ -220,9 +220,11 @@ export function TguPlanningPage() {
// Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели.
// planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор.
// Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе
// счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся).
const [planModeCounts, setPlanModeCounts] = useState<PlanModeCounts>();
useEffect(() => {
if (!selectedPlanId) {
if (!selectedPlanId || activeTab !== "timeline") {
setPlanModeCounts(undefined);
return;
}
@@ -242,7 +244,7 @@ export function TguPlanningPage() {
return () => {
cancelled = true;
};
}, [selectedPlanId]);
}, [selectedPlanId, activeTab]);
const applyRange = () => {
setDecisionNotice(undefined);
@@ -863,10 +863,16 @@
.tgu-editor-panel__actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6rem;
}
.tgu-editor-panel__actions > .tgu-button {
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
}
.tgu-editor-panel__head h2 {
margin: 0;
font-size: 0.95rem;