pcp-tgu-ops-ui: вкладка Редактор — план, включения, нижняя панель

- Слева выводим даты начала/конца плана и корректные счётчики
  съёмки/сброса (из plan modes, а не из заглушки-черновика).
- На таймлайне показываем реальные включения плана (read-only): убран
  фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры
  начала и конца плана; клик по включению открывает его инфо справа.
- Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются
  цветом, слева — подписи рядов.
- Нижняя панель: активная дорожка резиновая — примыкает к низу и не
  пустует, при подъёме панели ряды расширяются; высота бара ограничена
  потолком и центрируется в ряду, чтобы узкие включения не вытягивались.
- Время в редакторе парсится/форматируется через utils/utcTime.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 16:54:01 +03:00
parent fb0332a030
commit ac2220d063
7 changed files with 340 additions and 181 deletions
@@ -1,5 +1,6 @@
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import type { RvaZone } from "../../api/ballisticsApi"; import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
@@ -28,6 +29,8 @@ type WorkInspectorProps = {
modes: EditorMode[]; modes: EditorMode[];
stations: EditorGroundStation[]; stations: EditorGroundStation[];
conflicts: string[]; conflicts: string[];
/** Включение существующего плана — показываем только информацию, без правок. */
readOnly?: boolean;
onUpdate: (workId: string, patch: EditorWorkPatch) => void; onUpdate: (workId: string, patch: EditorWorkPatch) => void;
onDelete: (workId: string) => void; onDelete: (workId: string) => void;
onDuplicate: (workId: string) => void; onDuplicate: (workId: string) => void;
@@ -96,6 +99,7 @@ export function WorkInspector({
modes, modes,
stations, stations,
conflicts, conflicts,
readOnly,
onUpdate, onUpdate,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -118,14 +122,30 @@ export function WorkInspector({
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Время</div> <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.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 })} /> <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> <div className="tgu-editor-panel__hint">длительность: {formatDuration(work.endMs - work.startMs)}</div>
</>
)}
</section> </section>
{work.kind === "shoot" && ( {work.kind === "shoot" && (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Режим аппаратуры</div> <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"> <div className="tgu-editor-chip-row">
{modes.map((mode) => ( {modes.map((mode) => (
<button <button
@@ -138,6 +158,7 @@ export function WorkInspector({
</button> </button>
))} ))}
</div> </div>
)}
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>} {work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
</section> </section>
)} )}
@@ -145,6 +166,9 @@ export function WorkInspector({
{work.kind === "downlink" && ( {work.kind === "downlink" && (
<section className="tgu-editor-panel__section"> <section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Станция приёма</div> <div className="tgu-editor-eyebrow">Станция приёма</div>
{readOnly ? (
<div className="tgu-editor-panel__hint">{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}</div>
) : (
<select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}> <select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}>
{stations.map((station) => ( {stations.map((station) => (
<option key={station.id} value={station.id}> <option key={station.id} value={station.id}>
@@ -152,9 +176,11 @@ export function WorkInspector({
</option> </option>
))} ))}
</select> </select>
)}
</section> </section>
)} )}
{!readOnly && (
<div className="tgu-editor-panel__actions"> <div className="tgu-editor-panel__actions">
<button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}> <button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}>
Дублировать Дублировать
@@ -163,6 +189,7 @@ export function WorkInspector({
Удалить Удалить
</button> </button>
</div> </div>
)}
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" /> <span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
</div> </div>
@@ -556,7 +583,7 @@ function surveyTypeLabel(surveyType: string): string {
} }
function formatIso(iso: string): string { function formatIso(iso: string): string {
return new Date(iso).toLocaleString("ru-RU"); return formatUtcDateTime(parseUtc(iso));
} }
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
@@ -1,11 +1,12 @@
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes"; import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
import type { Draft, EditorWork } from "./model/editorTypes"; import { formatDateTime } from "./editorPresentation";
type EditorRailProps = { type EditorRailProps = {
selectedSpacecraftId?: string; selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi; selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[]; platforms: TguPlatformUi[];
draft: Draft; surveyCount: number;
downlinkCount: number;
conflictCount: number; conflictCount: number;
contextSpacecraftIds: Set<string>; contextSpacecraftIds: Set<string>;
onToggleContext: (spacecraftId: string) => void; onToggleContext: (spacecraftId: string) => void;
@@ -15,7 +16,8 @@ export function EditorRail({
selectedSpacecraftId, selectedSpacecraftId,
selectedPlan, selectedPlan,
platforms, platforms,
draft, surveyCount,
downlinkCount,
conflictCount, conflictCount,
contextSpacecraftIds, contextSpacecraftIds,
onToggleContext onToggleContext
@@ -32,10 +34,19 @@ export function EditorRail({
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"} {selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
</div> </div>
{selectedPlan && (
<dl className="tgu-editor-rail__plan-dates">
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedPlan.startMs)}</dd>
<dt>Окончание</dt>
<dd className="mono">{formatDateTime(selectedPlan.endMs)}</dd>
</dl>
)}
<div className="tgu-editor-stats"> <div className="tgu-editor-stats">
<RailStat label="Включений" value={draft.works.length} /> <RailStat label="Включений" value={surveyCount + downlinkCount} />
<RailStat label="Съёмка" value={countWorks(draft.works, "shoot")} color="var(--t-optical)" /> <RailStat label="Съёмка" value={surveyCount} color="var(--t-optical)" />
<RailStat label="Сброс" value={countWorks(draft.works, "downlink")} color="var(--accent)" /> <RailStat label="Сброс" value={downlinkCount} color="var(--accent)" />
<RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} /> <RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
</div> </div>
</section> </section>
@@ -76,7 +87,3 @@ function RailStat({ label, value, color }: { label: string; value: number; color
</div> </div>
); );
} }
function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number {
return works.filter((work) => work.kind === kind).length;
}
@@ -4,128 +4,86 @@ import type {
EditorContextLane, EditorContextLane,
EditorTimelineWindow, EditorTimelineWindow,
EditorVisibilityTrack, EditorVisibilityTrack,
EditorWork, EditorWork
EditorWorkPatch
} from "./model/editorTypes"; } from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks"; import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = { type EditorTimelineProps = {
activeLabel: string; activeLabel: string;
draft: EditorWork[]; works: EditorWork[];
selectedWorkId?: string; selectedWorkId?: string;
window: EditorTimelineWindow; window: EditorTimelineWindow;
nowMs: number; planStartMs?: number;
planEndMs?: number;
conflicts: Record<string, string[]>; conflicts: Record<string, string[]>;
contextLanes: EditorContextLane[]; contextLanes: EditorContextLane[];
visibilityTracks: EditorVisibilityTrack[]; visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void; onSelectWork: (workId: string) => void;
onClearSelection: () => void; onClearSelection: () => void;
onChangeWork: (workId: string, patch: EditorWorkPatch) => void;
}; };
const LABEL_WIDTH = 168; const LABEL_WIDTH = 168;
const AXIS_HEIGHT = 40; const AXIS_HEIGHT = 40;
const ACTIVE_HEIGHT = 66; /** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
const MIN_ACTIVE_HEIGHT = 84;
const VISIBILITY_HEIGHT = 24; const VISIBILITY_HEIGHT = 24;
const OTHER_HEIGHT = 30; 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({ export function EditorTimeline({
activeLabel, activeLabel,
draft, works,
selectedWorkId, selectedWorkId,
window: timelineWindow, window: timelineWindow,
nowMs, planStartMs,
planEndMs,
conflicts, conflicts,
contextLanes, contextLanes,
visibilityTracks, visibilityTracks,
onSelectWork, onSelectWork,
onClearSelection, onClearSelection
onChangeWork
}: EditorTimelineProps) { }: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const [bodyWidth, setBodyWidth] = useState(900); const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs); const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span; const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs; const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]); const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]); const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT; // Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT; const belowActiveHeight =
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
const totalHeight =
AXIS_HEIGHT +
ACTIVE_HEIGHT +
visibilityTracks.length * VISIBILITY_HEIGHT + visibilityTracks.length * VISIBILITY_HEIGHT +
(contextLanes.length > 0 ? 22 : 0) + (contextLanes.length > 0 ? 22 : 0) +
contextLanes.length * OTHER_HEIGHT + contextLanes.length * OTHER_HEIGHT +
8; 8;
// Активная дорожка резиновая: занимает всё свободное место панели, поэтому при
// подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует.
const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight);
const visibilityTop = AXIS_HEIGHT + activeHeight;
const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT;
const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0);
const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight;
const surfaceHeight = Math.max(bodyHeight, contentHeight);
useEffect(() => { useEffect(() => {
const element = wrapperRef.current; const element = wrapperRef.current;
if (!element) return; if (!element) return;
const observer = new ResizeObserver(() => { const measure = () => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
}); setBodyHeight(element.clientHeight);
};
const observer = new ResizeObserver(measure);
observer.observe(element); observer.observe(element);
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); measure();
return () => observer.disconnect(); 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 ( return (
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}> <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__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}> <div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время (UTC) Время (UTC)
</div> </div>
@@ -149,19 +107,27 @@ export function EditorTimeline({
))} ))}
</div> </div>
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && ( {planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}> <div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), top: AXIS_HEIGHT }}>
<span>Сейчас</span> <span>Начало плана</span>
</div> </div>
)} )}
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: ACTIVE_HEIGHT }}> {planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}> <div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--end" style={{ left: LABEL_WIDTH + toX(planEndMs), top: AXIS_HEIGHT }}>
<span>Конец плана</span>
</div>
)}
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: activeHeight }}>
<div className="tgu-editor-timeline__lane-label tgu-editor-timeline__lane-label--split" style={{ width: LABEL_WIDTH }}>
<span>{activeLabel}</span> <span>{activeLabel}</span>
<small>редактируется</small> <small>редактируется</small>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--shoot">Съёмка</em>
<em className="tgu-editor-timeline__row-hint tgu-editor-timeline__row-hint--downlink">Сброс</em>
</div> </div>
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}> <div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{draft.map((work) => { {works.map((work) => {
const left = toX(work.startMs); const left = toX(work.startMs);
const width = Math.max(6, toX(work.endMs) - toX(work.startMs)); const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
const color = kindColor(work.kind); const color = kindColor(work.kind);
@@ -169,8 +135,9 @@ export function EditorTimeline({
const hasConflict = Boolean(conflicts[work.id]?.length); const hasConflict = Boolean(conflicts[work.id]?.length);
return ( return (
<div <button
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`} className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
type="button"
key={work.id} key={work.id}
style={{ style={{
left, left,
@@ -179,19 +146,16 @@ export function EditorTimeline({
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))` background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
}} }}
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`} title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
onMouseDown={(event) => startDrag(event, work, "move")}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onSelectWork(work.id); 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 }} /> <span className="tgu-editor-work__dot" style={{ background: color }} />
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>} {width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
{work.isNew && <span className="tgu-editor-work__new">NEW</span>} {work.isNew && <span className="tgu-editor-work__new">NEW</span>}
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>} {width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
</div> </button>
); );
})} })}
</div> </div>
@@ -6,6 +6,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers"; import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
@@ -19,6 +20,7 @@ import {
deleteWork, deleteWork,
duplicateWork, duplicateWork,
mutate, mutate,
planModesToWorks,
redo, redo,
seedDraft, seedDraft,
undo, undo,
@@ -40,6 +42,7 @@ import type {
EditorTimelineWindow, EditorTimelineWindow,
EditorVisibilityTrack, EditorVisibilityTrack,
EditorVisibilityWindow, EditorVisibilityWindow,
EditorWork,
EditorWorkPatch, EditorWorkPatch,
TguEditorTabProps TguEditorTabProps
} from "./model/editorTypes"; } from "./model/editorTypes";
@@ -68,7 +71,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const idCounterRef = useRef(0); const idCounterRef = useRef(0);
const targetCounterRef = useRef(0); const targetCounterRef = useRef(0);
const centerRef = useRef<HTMLElement>(null); const centerRef = useRef<HTMLElement>(null);
const [mapRatio, setMapRatio] = useState(0.72); // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
// примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
const [mapRatio, setMapRatio] = useState(0.8);
const [history, setHistory] = useState<DraftHistory>(() => ({ const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId), draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [], past: [],
@@ -124,14 +129,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
.map(([revolution, pts], idx) => ({ .map(([revolution, pts], idx) => ({
index: idx + 1, index: idx + 1,
revolution, revolution,
startMs: Date.parse(pts[0].time), startMs: parseUtc(pts[0].time),
endMs: Date.parse(pts[pts.length - 1].time), endMs: parseUtc(pts[pts.length - 1].time),
})); }));
}, [flightLineData]); }, [flightLineData]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES; const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
const displayWorks = useMemo<EditorWork[]>(() => [...planWorks, ...draft.works], [planWorks, draft.works]);
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
const selectedWork = draft.works.find((work) => work.id === selectedWorkId); const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
const activeLabel = platformLabel(platforms, selectedSpacecraftId); const activeLabel = platformLabel(platforms, selectedSpacecraftId);
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]); const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesLoading, setShootPassesLoading] = useState(false);
@@ -187,6 +195,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
const addedCount = draft.works.filter((work) => work.isNew).length; const addedCount = draft.works.filter((work) => work.isNew).length;
// Реальный состав плана берём из включений (plan modes), а не из черновика:
// черновик сидируется заглушкой, когда у плана нет встроенных works.
const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]);
const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]);
useEffect(() => { useEffect(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
@@ -559,7 +571,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const el = centerRef.current; const el = centerRef.current;
if (!el) return; if (!el) return;
const dy = ev.clientY - startY; const dy = ev.clientY - startY;
setMapRatio(Math.max(0.15, Math.min(0.80, startRatio + dy / el.clientHeight))); setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight)));
}; };
const onUp = () => { const onUp = () => {
@@ -618,7 +630,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedSpacecraftId={selectedSpacecraftId} selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan} selectedPlan={selectedPlan}
platforms={platforms} platforms={platforms}
draft={draft} surveyCount={planSurveyCount}
downlinkCount={planDownlinkCount}
conflictCount={conflictCount(conflicts)} conflictCount={conflictCount(conflicts)}
contextSpacecraftIds={contextSpacecraftIds} contextSpacecraftIds={contextSpacecraftIds}
onToggleContext={(spacecraftId) => { onToggleContext={(spacecraftId) => {
@@ -681,10 +694,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
</div> </div>
<EditorTimeline <EditorTimeline
activeLabel={activeLabel} activeLabel={activeLabel}
draft={draft.works} works={displayWorks}
selectedWorkId={selectedWorkId} selectedWorkId={selectedWorkId}
window={window} window={window}
nowMs={Date.now()} planStartMs={selectedPlan?.startMs}
planEndMs={selectedPlan?.endMs}
conflicts={conflicts} conflicts={conflicts}
contextLanes={contextLanes} contextLanes={contextLanes}
visibilityTracks={visibilityTracks} visibilityTracks={visibilityTracks}
@@ -699,7 +713,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode("none"); setRightMode("none");
} }
}} }}
onChangeWork={onChangeWork}
/> />
</section> </section>
@@ -711,6 +724,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
modes={modes} modes={modes}
stations={stations} stations={stations}
conflicts={conflicts[selectedWork.id] ?? []} conflicts={conflicts[selectedWork.id] ?? []}
readOnly={selectedWork.origin === "plan"}
onUpdate={onChangeWork} onUpdate={onChangeWork}
onDelete={(workId) => { onDelete={(workId) => {
changeDraft((current) => deleteWork(current, workId)); changeDraft((current) => deleteWork(current, workId));
@@ -810,7 +824,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
} }
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = Date.parse(param.time); const timeMs = parseUtc(param.time);
const half = SHOOT_WINDOW_MS / 2; const half = SHOOT_WINDOW_MS / 2;
return { return {
startMs: timeMs - half, startMs: timeMs - half,
@@ -851,8 +865,7 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале. * зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
*/ */
function localDateTimeMs(s: string): number { function localDateTimeMs(s: string): number {
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s); return parseUtc(s);
return Date.parse(hasZone ? s : `${s}Z`);
} }
function buildVisibilityTracks( function buildVisibilityTracks(
@@ -6,11 +6,13 @@ import {
deleteWork, deleteWork,
duplicateWork, duplicateWork,
mutate, mutate,
planModesToWorks,
redo, redo,
seedDraft, seedDraft,
undo, undo,
updateWork updateWork
} from "./editorDraft"; } from "./editorDraft";
import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes"; import type { DraftHistory } from "./model/editorTypes";
const plan: TguPlanUi = { const plan: TguPlanUi = {
@@ -25,24 +27,81 @@ const plan: TguPlanUi = {
}; };
describe("editorDraft", () => { describe("editorDraft", () => {
it("seeds a draft from the current API plan envelope when embedded works are absent", () => { it("seeds an empty draft when the plan has no embedded works", () => {
// Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки.
expect(seedDraft(plan)).toEqual({ expect(seedDraft(plan)).toEqual({
planId: "plan-1", planId: "plan-1",
spacecraftId: "56756", spacecraftId: "56756",
works: [ works: []
{
id: "plan-1:plan-window",
kind: "downlink",
label: "План ТГУ · KPP-1",
startMs: plan.startMs,
endMs: plan.endMs,
stationId: "KPP-1",
origin: "plan"
}
]
}); });
}); });
it("maps plan modes to read-only timeline works", () => {
const modes: PlanMode[] = [
{
id: 11,
planId: 1,
timeStart: "2026-05-29T01:10:00",
revolution: 4321,
type: "SURVEY",
status: "PLANNED",
lat: 55.7,
longitude: 37.6,
duration: 180,
contourWkt: null,
roll: 0
},
{
id: 22,
planId: 1,
timeStart: "2026-05-29T01:40:00",
revolution: 4322,
type: "DROP",
station: "102",
duration: 120,
surveys: [11]
},
{
id: 33,
planId: 1,
timeStart: null,
revolution: 4323,
type: "SURVEY",
status: "PLANNED",
lat: 0,
longitude: 0,
duration: 60,
contourWkt: null,
roll: 0
}
];
const works = planModesToWorks(modes);
// Включение без времени старта пропускается, оба валидных — read-only (origin: "plan").
expect(works).toEqual([
{
id: "survey-11",
kind: "shoot",
label: "Съёмка · виток 4321",
startMs: Date.parse("2026-05-29T01:10:00Z"),
endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000,
origin: "plan",
targetLat: 55.7,
targetLon: 37.6
},
{
id: "drop-22",
kind: "downlink",
label: "Сброс · 102",
startMs: Date.parse("2026-05-29T01:40:00Z"),
endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000,
origin: "plan",
stationId: "102"
}
]);
});
it("adds shoot and downlink works with deterministic ids", () => { it("adds shoot and downlink works with deterministic ids", () => {
const draft = seedDraft(undefined, "56756"); const draft = seedDraft(undefined, "56756");
const withShoot = addShoot(draft, { const withShoot = addShoot(draft, {
@@ -88,10 +147,16 @@ describe("editorDraft", () => {
}); });
it("updates, duplicates and deletes works without generating ids internally", () => { it("updates, duplicates and deletes works without generating ids internally", () => {
const draft = seedDraft(plan); const draft = addDownlink(seedDraft(plan), {
const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 }); id: "downlink-1",
const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000); label: "Сброс на НКПОР",
const deleted = deleteWork(duplicated, "plan-1:plan-window"); startMs: plan.startMs,
endMs: plan.startMs + 120_000,
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");
expect(updated.works[0].label).toBe("Сеанс"); expect(updated.works[0].label).toBe("Сеанс");
expect(duplicated.works[1]).toMatchObject({ expect(duplicated.works[1]).toMatchObject({
@@ -1,4 +1,6 @@
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import type { PlanMode } from "./editorApi";
import type { import type {
Draft, Draft,
DraftHistory, DraftHistory,
@@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
return { spacecraftId, works: [] }; return { spacecraftId, works: [] };
} }
const embeddedWorks = readEmbeddedWorks(plan);
return { return {
planId: plan.planId, planId: plan.planId,
spacecraftId: plan.spacecraftId, spacecraftId: plan.spacecraftId,
works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)] // Черновик содержит только правки, внесённые в редакторе. Существующие включения
// плана грузятся отдельно (fetchPlanModes) и показываются как read-only.
works: readEmbeddedWorks(plan)
}; };
} }
/**
* Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения
* на таймлайне редактора. Это read-only представление фактического состава плана:
* SURVEY → съёмка, DROP → сброс на НКПОР.
*/
export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
return modes.flatMap((mode): EditorWork[] => {
if (mode.timeStart == null) return [];
const startMs = parseUtc(mode.timeStart);
if (!Number.isFinite(startMs)) return [];
const endMs = startMs + Math.max(1, mode.duration) * 1000;
if (mode.type === "SURVEY") {
return [{
id: `survey-${mode.id}`,
kind: "shoot",
label: `Съёмка · виток ${mode.revolution}`,
startMs,
endMs,
origin: "plan",
targetLat: mode.lat,
targetLon: mode.longitude
}];
}
return [{
id: `drop-${mode.id}`,
kind: "downlink",
label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР",
startMs,
endMs,
origin: "plan",
stationId: mode.station ?? undefined
}];
});
}
export function mutate( export function mutate(
history: DraftHistory, history: DraftHistory,
transform: (draft: Draft) => Draft, transform: (draft: Draft) => Draft,
@@ -154,18 +195,6 @@ export function duplicateWork(
return { ...draft, works: [...draft.works, duplicated] }; return { ...draft, works: [...draft.works, duplicated] };
} }
function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork {
return {
id: `${plan.planId}:plan-window`,
kind: "downlink",
label: `План ТГУ · ${plan.kppId}`,
startMs: plan.startMs,
endMs: plan.endMs,
stationId: plan.kppId,
origin: "plan"
};
}
function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] {
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
if (!Array.isArray(rawWorks)) { if (!Array.isArray(rawWorks)) {
@@ -217,7 +246,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
return value; return value;
} }
if (typeof value === "string") { if (typeof value === "string") {
const parsed = new Date(value).getTime(); const parsed = parseUtc(value);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
return undefined; return undefined;
@@ -147,6 +147,25 @@
font-weight: 700; font-weight: 700;
} }
.tgu-editor-rail__plan-dates {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
margin: 0;
}
.tgu-editor-rail__plan-dates dt {
color: var(--text-dim);
font-size: 0.66rem;
}
.tgu-editor-rail__plan-dates dd {
margin: 0;
color: var(--text);
font-size: 0.7rem;
text-align: right;
}
.tgu-editor-stats { .tgu-editor-stats {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -553,26 +572,34 @@
border-left: 1px solid var(--grid); border-left: 1px solid var(--grid);
} }
.tgu-editor-timeline__now { .tgu-editor-timeline__bound {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
z-index: 8; z-index: 8;
border-left: 1.5px dashed var(--now); border-left: 1.5px solid var(--accent);
pointer-events: none; pointer-events: none;
} }
.tgu-editor-timeline__now span { .tgu-editor-timeline__bound span {
position: absolute; position: absolute;
left: 0.18rem;
top: 0; top: 0;
border-radius: 3px; border-radius: 3px;
padding: 0 0.24rem; padding: 0 0.24rem;
background: var(--now); background: var(--accent);
color: var(--bg-base); color: var(--bg-base);
font-family: var(--mono); font-family: var(--mono);
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
}
.tgu-editor-timeline__bound--start span {
left: 0.18rem;
}
.tgu-editor-timeline__bound--end span {
right: 0.18rem;
} }
.tgu-editor-timeline__lane { .tgu-editor-timeline__lane {
@@ -622,11 +649,15 @@
gap: 0.28rem; gap: 0.28rem;
height: calc(100% - 1.5rem); height: calc(100% - 1.5rem);
overflow: hidden; overflow: hidden;
appearance: none;
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
border-radius: 5px; border-radius: 5px;
padding: 0.25rem 0.45rem; padding: 0.25rem 0.45rem;
cursor: grab; color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
user-select: none; user-select: none;
} }
@@ -649,20 +680,43 @@
pointer-events: none; pointer-events: none;
} }
.tgu-editor-work__resize { /* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс.
Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка —
чтобы узкие включения не растягивались в непропорционально высокие полоски. */
.tgu-editor-work--shoot,
.tgu-editor-work--downlink {
height: min(56px, calc(50% - 0.55rem));
transform: translateY(-50%);
}
.tgu-editor-work--shoot {
top: 25%;
}
.tgu-editor-work--downlink {
top: 75%;
}
.tgu-editor-timeline__row-hint {
position: absolute; position: absolute;
top: 0; right: 0.65rem;
bottom: 0; font-style: normal;
width: 0.45rem; font-size: 0.56rem;
cursor: ew-resize; font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
} }
.tgu-editor-work__resize--left { .tgu-editor-timeline__row-hint--shoot {
left: 0; top: 25%;
transform: translateY(-50%);
color: var(--t-optical);
} }
.tgu-editor-work__resize--right { .tgu-editor-timeline__row-hint--downlink {
right: 0; top: 75%;
transform: translateY(-50%);
color: var(--accent);
} }
.tgu-editor-work__dot { .tgu-editor-work__dot {