feat(tgu-ops-ui): add spacecraft work editor tab

Port the TGU editor prototype into pcp-tgu-ops-ui as a new editor tab, add draft/conflict/pass logic with tests, extend the 2D map picking API, and include editor styles/theme aliases and task/prototype docs.

Validation: npm run test; npm run build in services/pcp-tgu-ops-ui.
This commit is contained in:
Дмитрий Соловьев
2026-05-30 15:28:34 +03:00
parent c3a1a8b4a1
commit f084775cbb
58 changed files with 6609 additions and 7 deletions
@@ -0,0 +1,514 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { saveEditedPlan } from "./editorApi";
import {
addDownlink,
addShoot,
deleteWork,
duplicateWork,
mutate,
redo,
seedDraft,
undo,
updateWork
} from "./editorDraft";
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
import { EditorRail } from "./EditorRail";
import { EditorTimeline } from "./EditorTimeline";
import { EditorToolbar } from "./EditorToolbar";
import type {
DraftHistory,
EditorGroundStation,
EditorMode,
EditorTarget,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorVisibilityWindow,
EditorWorkPatch,
TguEditorTabProps
} from "./model/editorTypes";
type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
const HOUR_MS = 60 * 60 * 1000;
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
tracks: true,
swath: false,
planWorks: false,
stations: true,
spacecraftMarkers: false
};
export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) {
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
future: []
}));
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
const [selectedWorkId, setSelectedWorkId] = useState<string>();
const [target, setTarget] = useState<EditorTarget>();
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
const [selectedDownlink, setSelectedDownlink] = useState<DownlinkSelection>();
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
const draft = history.draft;
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = draft.works.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const shootPasses = useMemo(
() => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [],
[selectedSpacecraftId, target, window.fromMs, window.toMs]
);
const stationOptions = useMemo(
() => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan),
[selectedPlan, selectedSpacecraftId, stations, window]
);
const visibilityTracks = useMemo(
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
);
const mapScene = useMemo(
() => buildEditorMapScene(selectedSpacecraftId, window, stations),
[selectedSpacecraftId, stations, window]
);
const contextLanes = useMemo(
() =>
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
spacecraftId,
label: platformLabel(platforms, spacecraftId),
works: []
})),
[contextSpacecraftIds, platforms]
);
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
const addedCount = draft.works.filter((work) => work.isNew).length;
useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
setSelectedWorkId(undefined);
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
setSelectedDownlink(undefined);
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
setHistory((current) => mutate(current, transform));
setServiceConflicts({});
setNotice(undefined);
setApplyError(undefined);
};
const onChangeWork = (workId: string, patch: EditorWorkPatch) => {
changeDraft((current) => updateWork(current, workId, patch));
};
const addShootWork = () => {
if (!target || !selectedShootPass || !modeId) return;
const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000);
const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2);
const mode = modes.find((item) => item.id === modeId);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addShoot(current, {
id,
startMs,
endMs: startMs + durationMs,
label: mode?.label ?? "Съёмка",
modeId,
target
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setTarget(undefined);
setSelectedShootPass(undefined);
};
const addDownlinkWork = () => {
if (!selectedDownlink) return;
const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000);
const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2);
const id = nextWorkId(idCounterRef);
changeDraft((current) =>
addDownlink(current, {
id,
startMs,
endMs: startMs + durationMs,
label: "Сброс на НКПОР",
station: selectedDownlink.station
})
);
setSelectedWorkId(id);
setRightMode("inspect");
setSelectedDownlink(undefined);
};
const runCheck = () => {
setChecking(true);
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
setChecking(false);
setNotice("Проверка видимости выполнена для текущего черновика.");
};
const applyDraft = async () => {
const planId = selectedPlan?.planId ?? draft.planId;
if (!planId) {
setApplyError("Нельзя применить черновик: план не выбран.");
setNotice(undefined);
return;
}
setApplying(true);
setApplyError(undefined);
setNotice(undefined);
try {
const result = await saveEditedPlan({ planId, draft });
setNotice(result.message);
setHistory((current) => ({ ...current, past: [], future: [] }));
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
} finally {
setApplying(false);
}
};
const zoom = (factor: number) => {
setWindow((current) => {
const center = (current.fromMs + current.toMs) / 2;
const half = ((current.toMs - current.fromMs) / 2) * factor;
return { fromMs: center - half, toMs: center + half };
});
};
const pan = (factor: number) => {
setWindow((current) => {
const delta = (current.toMs - current.fromMs) * factor;
return { fromMs: current.fromMs + delta, toMs: current.toMs + delta };
});
};
if (!selectedSpacecraftId && !selectedPlan) {
return (
<main className="tgu-editor-workspace">
<div className="tgu-empty">Выберите КА или план, чтобы открыть редактор включений.</div>
</main>
);
}
return (
<main className="tgu-editor-workspace">
<EditorToolbar
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
canUndo={history.past.length > 0}
canRedo={history.future.length > 0}
checking={checking}
conflictCount={conflictCount(conflicts)}
addedCount={addedCount}
dirty={dirty}
applying={applying}
windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode}
onSetRightMode={(mode) => {
setRightMode(mode);
setSelectedWorkId(undefined);
setNotice(undefined);
}}
onUndo={() => setHistory((current) => undo(current))}
onRedo={() => setHistory((current) => redo(current))}
onCheck={runCheck}
onApply={applyDraft}
onZoom={zoom}
onPan={pan}
/>
{notice && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
<EditorRail
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
draft={draft}
conflictCount={conflictCount(conflicts)}
contextSpacecraftIds={contextSpacecraftIds}
onToggleContext={(spacecraftId) => {
setContextSpacecraftIds((current) => {
const next = new Set(current);
if (next.has(spacecraftId)) {
next.delete(spacecraftId);
} else {
next.add(spacecraftId);
}
return next;
});
}}
/>
<section className="tgu-editor-center">
<div className="tgu-editor-map-stage">
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
<Tgu2DMapView
scene={mapScene}
layers={EDITOR_MAP_LAYERS}
redrawToken={0}
pickMode={rightMode === "shoot"}
targets={target ? [target] : []}
onPickPoint={(point) => {
targetCounterRef.current += 1;
setTarget({
id: `target-${targetCounterRef.current}`,
name: `Цель ${targetCounterRef.current}`,
lat: point.lat,
lon: point.lon
});
setSelectedShootPass(undefined);
setModeId(DEFAULT_EDITOR_MODES[0].id);
setNotice(undefined);
}}
onObjectSelect={() => undefined}
/>
</div>
<EditorTimeline
activeLabel={activeLabel}
draft={draft.works}
selectedWorkId={selectedWorkId}
window={window}
nowMs={Date.now()}
conflicts={conflicts}
contextLanes={contextLanes}
visibilityTracks={visibilityTracks}
onSelectWork={(workId) => {
setSelectedWorkId(workId);
setRightMode("inspect");
setNotice(undefined);
}}
onClearSelection={() => {
if (rightMode === "inspect") {
setSelectedWorkId(undefined);
setRightMode("none");
}
}}
onChangeWork={onChangeWork}
/>
</section>
{rightMode !== "none" && (
<aside className="tgu-editor-side-panel">
{rightMode === "inspect" && selectedWork && (
<WorkInspector
work={selectedWork}
modes={modes}
stations={stations}
conflicts={conflicts[selectedWork.id] ?? []}
onUpdate={onChangeWork}
onDelete={(workId) => {
changeDraft((current) => deleteWork(current, workId));
setSelectedWorkId(undefined);
setRightMode("none");
}}
onDuplicate={(workId) => {
const id = nextWorkId(idCounterRef);
changeDraft((current) => duplicateWork(current, workId, id));
setSelectedWorkId(id);
}}
onClose={() => {
setSelectedWorkId(undefined);
setRightMode("none");
}}
/>
)}
{rightMode === "inspect" && !selectedWork && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
{rightMode === "shoot" && (
<ShootPanel
target={target}
passes={shootPasses}
selectedPass={selectedShootPass}
modeId={modeId}
modes={modes}
onSelectPass={setSelectedShootPass}
onModeChange={setModeId}
onAdd={addShootWork}
onClose={() => {
setRightMode("none");
setTarget(undefined);
setSelectedShootPass(undefined);
}}
onClearTarget={() => {
setTarget(undefined);
setSelectedShootPass(undefined);
}}
/>
)}
{rightMode === "downlink" && (
<DownlinkPanel
stationOptions={stationOptions}
selected={selectedDownlink}
onSelect={setSelectedDownlink}
onAdd={addDownlinkWork}
onClose={() => {
setRightMode("none");
setSelectedDownlink(undefined);
}}
/>
)}
</aside>
)}
</div>
</main>
);
}
function defaultEditorWindow(
selectedPlan: TguEditorTabProps["selectedPlan"],
appliedRange: TguEditorTabProps["appliedRange"]
): EditorTimelineWindow {
if (selectedPlan) {
return {
fromMs: selectedPlan.startMs - 3 * HOUR_MS,
toMs: selectedPlan.endMs + 3 * HOUR_MS
};
}
return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs };
}
function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] {
if (!selectedPlan?.kppId) {
return [];
}
return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }];
}
function buildStationOptions(
stations: EditorGroundStation[],
spacecraftId: string | undefined,
window: EditorTimelineWindow,
selectedPlan: TguEditorTabProps["selectedPlan"]
): DownlinkStationOption[] {
return stations.map((station) => {
const computedPasses =
station.lat !== undefined && station.lon !== undefined
? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
: [];
const fallbackPass = selectedPlan && selectedPlan.kppId === station.id
? [{
startMs: Math.max(window.fromMs, selectedPlan.startMs),
endMs: Math.min(window.toMs, selectedPlan.endMs),
quality: 0
}]
: [];
return {
station,
passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs)
};
});
}
function buildVisibilityTracks(
target: EditorTarget | undefined,
shootPasses: EditorVisibilityWindow[],
selectedWork: DraftHistory["draft"]["works"][number] | undefined,
stations: EditorGroundStation[],
spacecraftId: string | undefined,
window: EditorTimelineWindow
): EditorVisibilityTrack[] {
const tracks: EditorVisibilityTrack[] = [];
if (target) {
tracks.push({
id: "target",
label: target.name,
kind: "target",
color: "var(--now)",
windows: shootPasses
});
}
if (selectedWork?.kind === "downlink" && selectedWork.stationId) {
const station = stations.find((item) => item.id === selectedWork.stationId);
if (station?.lat !== undefined && station.lon !== undefined) {
tracks.push({
id: "station",
label: station.name,
kind: "station",
color: "var(--accent)",
windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs })
});
}
}
return tracks;
}
function buildEditorMapScene(
spacecraftId: string | undefined,
window: EditorTimelineWindow,
stations: EditorGroundStation[]
): Tgu2DMapScene {
const lines = spacecraftId
? groundTrack({ spacecraftId }, { fromMs: window.fromMs, toMs: window.toMs }, 260).map((points, index) => ({
id: `${spacecraftId}:track:${index}`,
layer: "tracks" as const,
points,
spacecraftId,
zIndex: index
}))
: [];
const markers = stations.flatMap((station) => {
if (station.lat === undefined || station.lon === undefined) {
return [];
}
return [{
id: station.id,
layer: "stations" as const,
point: { lat: station.lat, lon: station.lon },
label: station.name,
zIndex: 10
}];
});
return { polygons: [], lines, markers };
}
function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string {
if (!spacecraftId) {
return "КА не выбран";
}
const platform = platforms.find((item) => item.spacecraftId === spacecraftId);
return platform?.name ?? platform?.noradId ?? spacecraftId;
}
function nextWorkId(ref: MutableRefObject<number>): string {
ref.current += 1;
return `NW-${String(ref.current).padStart(4, "0")}`;
}