5235142fa6
- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
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;
|
||
}
|