Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx
T
Дмитрий Соловьев ac2220d063 pcp-tgu-ops-ui: вкладка Редактор — план, включения, нижняя панель
- Слева выводим даты начала/конца плана и корректные счётчики
  съёмки/сброса (из plan modes, а не из заглушки-черновика).
- На таймлайне показываем реальные включения плана (read-only): убран
  фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры
  начала и конца плана; клик по включению открывает его инфо справа.
- Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются
  цветом, слева — подписи рядов.
- Нижняя панель: активная дорожка резиновая — примыкает к низу и не
  пустует, при подъёме панели ряды расширяются; высота бара ограничена
  потолком и центрируется в ряду, чтобы узкие включения не вытягивались.
- Время в редакторе парсится/форматируется через utils/utcTime.
2026-06-02 16:54:01 +03:00

216 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import type {
EditorContextLane,
EditorTimelineWindow,
EditorVisibilityTrack,
EditorWork
} from "./model/editorTypes";
import { VisibilityTracks } from "./VisibilityTracks";
type EditorTimelineProps = {
activeLabel: string;
works: EditorWork[];
selectedWorkId?: string;
window: EditorTimelineWindow;
planStartMs?: number;
planEndMs?: number;
conflicts: Record<string, string[]>;
contextLanes: EditorContextLane[];
visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void;
onClearSelection: () => void;
};
const LABEL_WIDTH = 168;
const AXIS_HEIGHT = 40;
/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */
const MIN_ACTIVE_HEIGHT = 84;
const VISIBILITY_HEIGHT = 24;
const OTHER_HEIGHT = 30;
export function EditorTimeline({
activeLabel,
works,
selectedWorkId,
window: timelineWindow,
planStartMs,
planEndMs,
conflicts,
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
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 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 measure = () => {
setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH));
setBodyHeight(element.clientHeight);
};
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, []);
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, surfaceHeight) }}>
<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>
{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>
)}
{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 }}>
{works.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 (
<button
className={`tgu-editor-work tgu-editor-work--${work.kind} ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
type="button"
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)}`}
onClick={(event) => {
event.stopPropagation();
onSelectWork(work.id);
}}
>
<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>}
</button>
);
})}
</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;
}