Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx
T
Дмитрий Соловьев 5235142fa6 pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок
- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика
- редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара
- окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор)
- таймлайн: drag по горизонтали как карту; минимум окна ~10с
- ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
2026-06-04 14:20:01 +03:00

299 lines
13 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, formatTick, 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;
/** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */
onWheelZoom?: (factor: number, anchorMs: number) => void;
/** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */
onPanTo?: (fromMs: number) => 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,
onWheelZoom,
onPanTo
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
const [grabbing, setGrabbing] = useState(false);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const { ticks, step: tickStep } = 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();
}, []);
// Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз).
const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom });
wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom };
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
// Нативный слушатель с passive: false — иначе preventDefault не сработает и страница
// прокрутится вместо масштабирования таймлайна.
const handleWheel = (event: WheelEvent) => {
const state = wheelStateRef.current;
if (!state.onWheelZoom) return;
event.preventDefault();
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left - LABEL_WIDTH;
const clampedX = Math.min(Math.max(x, 0), state.bodyWidth);
const anchorMs = state.fromMs + clampedX / state.pxPerMs;
const factor = event.deltaY > 0 ? 1.1 : 0.9;
state.onWheelZoom(factor, anchorMs);
};
element.addEventListener("wheel", handleWheel, { passive: false });
return () => element.removeEventListener("wheel", handleWheel);
}, []);
// Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую
// границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем
// как drag, чтобы погасить последующий click (иначе выделение/сброс включения).
const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null);
const suppressClickRef = useRef(false);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return;
const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false };
dragRef.current = drag;
const onMove = (move: MouseEvent) => {
const deltaPx = move.clientX - drag.startX;
if (Math.abs(deltaPx) > 3) {
drag.moved = true;
setGrabbing(true);
}
// Тянем содержимое вправо → показываем более раннее время (окно едет влево).
onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs);
};
const onUp = () => {
if (drag.moved) suppressClickRef.current = true;
dragRef.current = null;
setGrabbing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона.
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.stopPropagation();
event.preventDefault();
}
};
return (
<div
className="tgu-editor-timeline"
ref={wrapperRef}
onClick={onClearSelection}
onClickCapture={handleClickCapture}
onMouseDown={handleMouseDown}
style={onPanTo ? { cursor: grabbing ? "grabbing" : "grab" } : undefined}
>
<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>
<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) }}>
{formatTick(tick, tickStep)}
</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 = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
while (dayMs <= window.toMs) {
result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000;
}
return result;
}