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:
@@ -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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user