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 { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
@@ -28,6 +29,8 @@ type WorkInspectorProps = {
modes: EditorMode[];
stations: EditorGroundStation[];
conflicts: string[];
/** Включение существующего плана — показываем только информацию, без правок. */
readOnly?: boolean;
onUpdate: (workId: string, patch: EditorWorkPatch) => void;
onDelete: (workId: string) => void;
onDuplicate: (workId: string) => void;
@@ -96,6 +99,7 @@ export function WorkInspector({
modes,
stations,
conflicts,
readOnly,
onUpdate,
onDelete,
onDuplicate,
@@ -118,26 +122,43 @@ export function WorkInspector({
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Время</div>
<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>
{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>
</>
)}
</section>
{work.kind === "shoot" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Режим аппаратуры</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>
{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>
)}
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
</section>
)}
@@ -145,24 +166,30 @@ export function WorkInspector({
{work.kind === "downlink" && (
<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>
{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 })}>
{stations.map((station) => (
<option key={station.id} value={station.id}>
{station.name}
</option>
))}
</select>
)}
</section>
)}
<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 && (
<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>
)}
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
</div>
@@ -556,7 +583,7 @@ function surveyTypeLabel(surveyType: 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 }) {
@@ -1,11 +1,12 @@
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
import type { Draft, EditorWork } from "./model/editorTypes";
import { formatDateTime } from "./editorPresentation";
type EditorRailProps = {
selectedSpacecraftId?: string;
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
draft: Draft;
surveyCount: number;
downlinkCount: number;
conflictCount: number;
contextSpacecraftIds: Set<string>;
onToggleContext: (spacecraftId: string) => void;
@@ -15,7 +16,8 @@ export function EditorRail({
selectedSpacecraftId,
selectedPlan,
platforms,
draft,
surveyCount,
downlinkCount,
conflictCount,
contextSpacecraftIds,
onToggleContext
@@ -32,10 +34,19 @@ export function EditorRail({
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
</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">
<RailStat label="Включений" value={draft.works.length} />
<RailStat label="Съёмка" value={countWorks(draft.works, "shoot")} color="var(--t-optical)" />
<RailStat label="Сброс" value={countWorks(draft.works, "downlink")} color="var(--accent)" />
<RailStat label="Включений" value={surveyCount + downlinkCount} />
<RailStat label="Съёмка" value={surveyCount} color="var(--t-optical)" />
<RailStat label="Сброс" value={downlinkCount} color="var(--accent)" />
<RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
</div>
</section>
@@ -76,7 +87,3 @@ function RailStat({ label, value, color }: { label: string; value: number; color
</div>
);
}
function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number {
return works.filter((work) => work.kind === kind).length;
}
@@ -4,128 +4,86 @@ import type {
EditorContextLane,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorWork,
EditorWorkPatch
EditorWork
} from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = {
activeLabel: string;
draft: EditorWork[];
works: EditorWork[];
selectedWorkId?: string;
window: EditorTimelineWindow;
nowMs: number;
planStartMs?: number;
planEndMs?: 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 MIN_ACTIVE_HEIGHT = 84;
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,
works,
selectedWorkId,
window: timelineWindow,
nowMs,
planStartMs,
planEndMs,
conflicts,
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection,
onChangeWork
onClearSelection
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined);
const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
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 +
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
const belowActiveHeight =
visibilityTracks.length * VISIBILITY_HEIGHT +
(contextLanes.length > 0 ? 22 : 0) +
contextLanes.length * OTHER_HEIGHT +
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(() => {
const element = wrapperRef.current;
if (!element) return;
const observer = new ResizeObserver(() => {
const measure = () => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
});
setBodyHeight(element.clientHeight);
};
const observer = new ResizeObserver(measure);
observer.observe(element);
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
measure();
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__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время (UTC)
</div>
@@ -149,19 +107,27 @@ export function EditorTimeline({
))}
</div>
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}>
<span>Сейчас</span>
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
<div className="tgu-editor-timeline__bound tgu-editor-timeline__bound--start" style={{ left: LABEL_WIDTH + toX(planStartMs), 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 }}>
{planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
<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>
<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 className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{draft.map((work) => {
{works.map((work) => {
const left = toX(work.startMs);
const width = Math.max(6, toX(work.endMs) - toX(work.startMs));
const color = kindColor(work.kind);
@@ -169,8 +135,9 @@ export function EditorTimeline({
const hasConflict = Boolean(conflicts[work.id]?.length);
return (
<div
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
<button
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
type="button"
key={work.id}
style={{
left,
@@ -179,19 +146,16 @@ export function EditorTimeline({
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>
</button>
);
})}
</div>
@@ -6,6 +6,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi";
import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
@@ -19,6 +20,7 @@ import {
deleteWork,
duplicateWork,
mutate,
planModesToWorks,
redo,
seedDraft,
undo,
@@ -40,6 +42,7 @@ import type {
EditorTimelineWindow,
EditorVisibilityTrack,
EditorVisibilityWindow,
EditorWork,
EditorWorkPatch,
TguEditorTabProps
} from "./model/editorTypes";
@@ -68,7 +71,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const idCounterRef = useRef(0);
const targetCounterRef = useRef(0);
const centerRef = useRef<HTMLElement>(null);
const [mapRatio, setMapRatio] = useState(0.72);
// Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно
// примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель.
const [mapRatio, setMapRatio] = useState(0.8);
const [history, setHistory] = useState<DraftHistory>(() => ({
draft: seedDraft(selectedPlan, selectedSpacecraftId),
past: [],
@@ -124,14 +129,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
.map(([revolution, pts], idx) => ({
index: idx + 1,
revolution,
startMs: Date.parse(pts[0].time),
endMs: Date.parse(pts[pts.length - 1].time),
startMs: parseUtc(pts[0].time),
endMs: parseUtc(pts[pts.length - 1].time),
}));
}, [flightLineData]);
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 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 [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
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 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(() => {
setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] });
@@ -559,7 +571,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const el = centerRef.current;
if (!el) return;
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 = () => {
@@ -618,7 +630,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
platforms={platforms}
draft={draft}
surveyCount={planSurveyCount}
downlinkCount={planDownlinkCount}
conflictCount={conflictCount(conflicts)}
contextSpacecraftIds={contextSpacecraftIds}
onToggleContext={(spacecraftId) => {
@@ -681,10 +694,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
</div>
<EditorTimeline
activeLabel={activeLabel}
draft={draft.works}
works={displayWorks}
selectedWorkId={selectedWorkId}
window={window}
nowMs={Date.now()}
planStartMs={selectedPlan?.startMs}
planEndMs={selectedPlan?.endMs}
conflicts={conflicts}
contextLanes={contextLanes}
visibilityTracks={visibilityTracks}
@@ -699,7 +713,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setRightMode("none");
}
}}
onChangeWork={onChangeWork}
/>
</section>
@@ -711,6 +724,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
modes={modes}
stations={stations}
conflicts={conflicts[selectedWork.id] ?? []}
readOnly={selectedWork.origin === "plan"}
onUpdate={onChangeWork}
onDelete={(workId) => {
changeDraft((current) => deleteWork(current, workId));
@@ -810,7 +824,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = Date.parse(param.time);
const timeMs = parseUtc(param.time);
const half = SHOOT_WINDOW_MS / 2;
return {
startMs: timeMs - half,
@@ -851,8 +865,7 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
*/
function localDateTimeMs(s: string): number {
const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
return Date.parse(hasZone ? s : `${s}Z`);
return parseUtc(s);
}
function buildVisibilityTracks(
@@ -6,11 +6,13 @@ import {
deleteWork,
duplicateWork,
mutate,
planModesToWorks,
redo,
seedDraft,
undo,
updateWork
} from "./editorDraft";
import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes";
const plan: TguPlanUi = {
@@ -25,24 +27,81 @@ const plan: TguPlanUi = {
};
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({
planId: "plan-1",
spacecraftId: "56756",
works: [
{
id: "plan-1:plan-window",
kind: "downlink",
label: "План ТГУ · KPP-1",
startMs: plan.startMs,
endMs: plan.endMs,
stationId: "KPP-1",
origin: "plan"
}
]
works: []
});
});
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", () => {
const draft = seedDraft(undefined, "56756");
const withShoot = addShoot(draft, {
@@ -88,10 +147,16 @@ describe("editorDraft", () => {
});
it("updates, duplicates and deletes works without generating ids internally", () => {
const draft = seedDraft(plan);
const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 });
const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000);
const deleted = deleteWork(duplicated, "plan-1:plan-window");
const draft = addDownlink(seedDraft(plan), {
id: "downlink-1",
label: "Сброс на НКПОР",
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(duplicated.works[1]).toMatchObject({
@@ -1,4 +1,6 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import type { PlanMode } from "./editorApi";
import type {
Draft,
DraftHistory,
@@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
return { spacecraftId, works: [] };
}
const embeddedWorks = readEmbeddedWorks(plan);
return {
planId: plan.planId,
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(
history: DraftHistory,
transform: (draft: Draft) => Draft,
@@ -154,18 +195,6 @@ export function duplicateWork(
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[] {
const rawWorks = (plan as TguPlanUi & { works?: unknown }).works;
if (!Array.isArray(rawWorks)) {
@@ -217,7 +246,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
return value;
}
if (typeof value === "string") {
const parsed = new Date(value).getTime();
const parsed = parseUtc(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
@@ -147,6 +147,25 @@
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -553,26 +572,34 @@
border-left: 1px solid var(--grid);
}
.tgu-editor-timeline__now {
.tgu-editor-timeline__bound {
position: absolute;
bottom: 0;
z-index: 8;
border-left: 1.5px dashed var(--now);
border-left: 1.5px solid var(--accent);
pointer-events: none;
}
.tgu-editor-timeline__now span {
.tgu-editor-timeline__bound span {
position: absolute;
left: 0.18rem;
top: 0;
border-radius: 3px;
padding: 0 0.24rem;
background: var(--now);
background: var(--accent);
color: var(--bg-base);
font-family: var(--mono);
font-size: 0.55rem;
font-weight: 700;
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 {
@@ -622,11 +649,15 @@
gap: 0.28rem;
height: calc(100% - 1.5rem);
overflow: hidden;
appearance: none;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 5px;
padding: 0.25rem 0.45rem;
cursor: grab;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
user-select: none;
}
@@ -649,20 +680,43 @@
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;
top: 0;
bottom: 0;
width: 0.45rem;
cursor: ew-resize;
right: 0.65rem;
font-style: normal;
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.tgu-editor-work__resize--left {
left: 0;
.tgu-editor-timeline__row-hint--shoot {
top: 25%;
transform: translateY(-50%);
color: var(--t-optical);
}
.tgu-editor-work__resize--right {
right: 0;
.tgu-editor-timeline__row-hint--downlink {
top: 75%;
transform: translateY(-50%);
color: var(--accent);
}
.tgu-editor-work__dot {