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; 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(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) => { 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) => { if (suppressClickRef.current) { suppressClickRef.current = false; event.stopPropagation(); event.preventDefault(); } }; return (
Время
{days.map((day) => (
{formatDate(day).split(" ")[0]} {formatDate(day).split(" ")[1]}
))} {ticks.map((tick) => (
{formatTick(tick, tickStep)}
))}
{ticks.map((tick) => (
))}
{planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && (
Начало плана
)} {planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && (
Конец плана
)}
{activeLabel} редактируется Съёмка Сброс
{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 ( ); })}
{contextLanes.length > 0 && (
Другие КА · контекст (read-only)
)} {contextLanes.map((lane, index) => (
{lane.label}
{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 (
); })}
))}
); } 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; }