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,251 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import type {
EditorContextLane,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorWork,
EditorWorkPatch
} from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = {
activeLabel: string;
draft: EditorWork[];
selectedWorkId?: string;
window: EditorTimelineWindow;
nowMs: number;
conflicts: Record<string, string[]>;
contextLanes: EditorContextLane[];
visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void;
onClearSelection: () => void;
onChangeWork: (workId: string, patch: EditorWorkPatch) => void;
};
const LABEL_WIDTH = 168;
const AXIS_HEIGHT = 40;
const ACTIVE_HEIGHT = 66;
const VISIBILITY_HEIGHT = 24;
const OTHER_HEIGHT = 30;
const SNAP_MS = 60 * 1000;
type DragState = {
workId: string;
mode: "move" | "left" | "right";
startX: number;
startMs: number;
endMs: number;
};
export function EditorTimeline({
activeLabel,
draft,
selectedWorkId,
window: timelineWindow,
nowMs,
conflicts,
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection,
onChangeWork
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const [bodyWidth, setBodyWidth] = useState(900);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT;
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
const totalHeight =
AXIS_HEIGHT +
ACTIVE_HEIGHT +
visibilityTracks.length * VISIBILITY_HEIGHT +
(contextLanes.length > 0 ? 22 : 0) +
contextLanes.length * OTHER_HEIGHT +
8;
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
const observer = new ResizeObserver(() => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
});
observer.observe(element);
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
return () => observer.disconnect();
}, []);
useEffect(() => {
const onMove = (event: MouseEvent) => {
const drag = dragRef.current;
if (!drag) return;
const deltaMs = Math.round(((event.clientX - drag.startX) / pxPerMs) / SNAP_MS) * SNAP_MS;
if (drag.mode === "move") {
onChangeWork(drag.workId, { startMs: drag.startMs + deltaMs, endMs: drag.endMs + deltaMs });
} else if (drag.mode === "left") {
onChangeWork(drag.workId, { startMs: Math.min(drag.endMs - SNAP_MS, drag.startMs + deltaMs) });
} else {
onChangeWork(drag.workId, { endMs: Math.max(drag.startMs + SNAP_MS, drag.endMs + deltaMs) });
}
};
const onUp = () => {
dragRef.current = undefined;
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [onChangeWork, pxPerMs]);
const startDrag = (event: React.MouseEvent, work: EditorWork, mode: DragState["mode"]) => {
event.preventDefault();
event.stopPropagation();
dragRef.current = {
workId: work.id,
mode,
startX: event.clientX,
startMs: work.startMs,
endMs: work.endMs
};
onSelectWork(work.id);
};
return (
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, totalHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время (UTC)
</div>
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{days.map((day) => (
<div className="tgu-editor-timeline__day" key={day} style={{ left: toX(day) }}>
<strong>{formatDate(day).split(" ")[0]}</strong>
<span>{formatDate(day).split(" ")[1]}</span>
</div>
))}
{ticks.map((tick) => (
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
{formatTime(tick)}
</div>
))}
</div>
<div className="tgu-editor-timeline__grid" style={{ left: LABEL_WIDTH, top: AXIS_HEIGHT, width: bodyWidth }}>
{ticks.map((tick) => (
<div key={tick} style={{ left: toX(tick) }} />
))}
</div>
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}>
<span>Сейчас</span>
</div>
)}
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: ACTIVE_HEIGHT }}>
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
<span>{activeLabel}</span>
<small>редактируется</small>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{draft.map((work) => {
const left = toX(work.startMs);
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
const color = kindColor(work.kind);
const selected = selectedWorkId === work.id;
const hasConflict = Boolean(conflicts[work.id]?.length);
return (
<div
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
key={work.id}
style={{
left,
width,
borderLeftColor: color,
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
}}
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
onMouseDown={(event) => startDrag(event, work, "move")}
onClick={(event) => {
event.stopPropagation();
onSelectWork(work.id);
}}
>
<span className="tgu-editor-work__resize tgu-editor-work__resize--left" onMouseDown={(event) => startDrag(event, work, "left")} />
<span className="tgu-editor-work__resize tgu-editor-work__resize--right" onMouseDown={(event) => startDrag(event, work, "right")} />
<span className="tgu-editor-work__dot" style={{ background: color }} />
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
{work.isNew && <span className="tgu-editor-work__new">NEW</span>}
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
</div>
);
})}
</div>
</div>
<VisibilityTracks
tracks={visibilityTracks}
window={timelineWindow}
width={bodyWidth}
labelWidth={LABEL_WIDTH}
top={visibilityTop}
rowHeight={VISIBILITY_HEIGHT}
/>
{contextLanes.length > 0 && (
<div className="tgu-editor-context-divider" style={{ top: dividerTop, width: LABEL_WIDTH + bodyWidth }}>
Другие КА · контекст (read-only)
</div>
)}
{contextLanes.map((lane, index) => (
<div className="tgu-editor-timeline__lane" key={lane.spacecraftId} style={{ top: contextTop + index * OTHER_HEIGHT, height: OTHER_HEIGHT }}>
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
<span>{lane.label}</span>
</div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{lane.works.map((work) => {
if (work.endMs < timelineWindow.fromMs || work.startMs > timelineWindow.toMs) return null;
const left = toX(work.startMs);
const width = Math.max(3, toX(work.endMs) - toX(work.startMs));
return (
<div
className="tgu-editor-work tgu-editor-work--context"
key={work.id}
style={{ left, width, borderLeftColor: kindColor(work.kind) }}
title={`${lane.label} · ${kindLabel(work.kind)} · ${formatTime(work.startMs)}-${formatTime(work.endMs)}`}
/>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
function buildDays(window: EditorTimelineWindow): number[] {
const result: number[] = [];
const start = new Date(window.fromMs);
let dayMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
while (dayMs <= window.toMs) {
result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000;
}
return result;
}