pcp-tgu-ops-ui: правка включения через окно построения + обновление состава плана
Редактор: инспектор включения теперь только просмотр (время с десятыми долями секунды), кнопки «Редактировать»→«Удалить». «Редактировать» открывает окно построения, предзаполненное параметрами, и при сохранении заменяет маршрут. Прицеливание опорной точки — явный режим: активно, пока не выбран проход. Планы: состав включений выбранного плана перечитывается при возврате на вкладку «Планы», иначе счётчик маршрутов оставался прежним после правок в редакторе.
This commit is contained in:
@@ -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>
|
||||
<dt>Конец</dt>
|
||||
<dd className="mono">{formatDateTime(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>
|
||||
</>
|
||||
)}
|
||||
<dl className="tgu-map-request-details__grid">
|
||||
<dt>Начало</dt>
|
||||
<dd className="mono">{formatDateTimeSeconds(work.startMs)}</dd>
|
||||
<dt>Конец</dt>
|
||||
<dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd>
|
||||
<dt>Длительность</dt>
|
||||
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
|
||||
</dl>
|
||||
</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>
|
||||
)}
|
||||
<div className="tgu-editor-panel__hint">{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}</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)}>
|
||||
Дублировать
|
||||
</button>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -303,6 +272,9 @@ export function ShootPanel({
|
||||
routeLoading,
|
||||
routeError,
|
||||
route,
|
||||
editing,
|
||||
picking,
|
||||
onPickTarget,
|
||||
addBlockedReason,
|
||||
onRouteStartChange,
|
||||
onRouteEndChange,
|
||||
@@ -312,32 +284,42 @@ 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 ? (
|
||||
{target ? (
|
||||
<>
|
||||
<div className="tgu-editor-target">
|
||||
<i />
|
||||
<span>{target.name}</span>
|
||||
<small>
|
||||
{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>
|
||||
) : (
|
||||
<div className="tgu-editor-target">
|
||||
<i />
|
||||
<span>{target.name}</span>
|
||||
<small>
|
||||
{target.lat.toFixed(2)}, {target.lon.toFixed(2)}
|
||||
</small>
|
||||
</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);
|
||||
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)` }}
|
||||
>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user