import { useLayoutEffect, useMemo, useRef, useState } from "react"; import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import { buildSequentialLinks, buildTimeTicks, buildTimelineLayout, TIMELINE_AXIS_HEIGHT, TIMELINE_BOTTOM_PADDING, TIMELINE_LABEL_WIDTH, TIMELINE_ZRV_BAND_HEIGHT, timeToX } from "./tguTimelineLayout"; import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime"; export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; type ZrvBands = { spacecraftId: string; windows: ZrvWindow[]; }; type TguTimelineProps = { rows: TimelineRow[]; range: TimelineRange; selectedPlanId?: string; invalidRange?: boolean; /** ЗРВ выбранного КА — подсветка доступных зон на его строке. */ zrvBands?: ZrvBands; /** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */ selectedZrvId?: string; /** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */ pausedSpacecraftIds?: ReadonlySet; onSelectPlan: (planId: string) => void; /** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */ onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void; }; function zrvId(zrv: ZrvWindow): string { return `${zrv.kppId}|${zrv.startMs}`; } export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, selectedZrvId, pausedSpacecraftIds, onSelectPlan, onSelectZrv }: TguTimelineProps) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(1200); const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28); const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth; const nowMs = Date.now(); useLayoutEffect(() => { if (!containerRef.current) return; setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width)); const observer = new ResizeObserver(([entry]) => { setContainerWidth(Math.floor(entry.contentRect.width)); }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); const layout = useMemo( () => buildTimelineLayout(rows, range, timelineWidth), [rows, range, timelineWidth] ); const segments = layout.segments; const height = Math.max(260, layout.height); const segmentByPlanId = useMemo( () => new Map(segments.map((segment) => [segment.plan.planId, segment])), [segments] ); const links = useMemo(() => buildSequentialLinks(rows), [rows]); const ticks = useMemo(() => buildTimeTicks(range), [range]); const nowX = nowMs >= range.fromMs && nowMs <= range.toMs ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth) : undefined; return (
{invalidRange ? (
Некорректный интервал: from должен быть меньше to.
) : rows.length === 0 || segments.length === 0 ? (
Нет планов в выбранном интервале.
) : ( {ticks.map((tick) => { const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth); return ( {formatTickDate(tick)} {formatTickTime(tick)} ); })} {nowX !== undefined && ( сейчас )} {layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => { return ( {platformLabel(row.platform, row.spacecraftId)} NORAD {row.platform?.noradId || row.spacecraftId} {pausedSpacecraftIds?.has(row.spacecraftId) && ( ⏸ на паузе Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»). )} {zrvBands?.spacecraftId === row.spacecraftId && ( <> ЗРВ {zrvBands.windows.map((zrv, zrvIndex) => { const from = Math.max(zrv.startMs, range.fromMs); const to = Math.min(zrv.endMs, range.toMs); if (to <= from) return null; const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth); const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth)); const id = zrvId(zrv); return ( { if (!onSelectZrv) return; event.stopPropagation(); // Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени // (разные КПП могут перекрываться), как «заявки в точке» на карте. const overlapping = zrvBands.windows.filter( (w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs ); const idx = overlapping.findIndex((w) => zrvId(w) === id); onSelectZrv(overlapping, Math.max(0, idx)); }} > ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)} ); })} )} ); })} {links.map((link) => { const from = segmentByPlanId.get(link.fromPlanId); const to = segmentByPlanId.get(link.toPlanId); if (!from || !to) return null; return ; })} {segments.map((segment) => { const y = segment.centerY; const x1 = TIMELINE_LABEL_WIDTH + segment.x; const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width; const style = statusStyle(segment.plan.status); const selected = selectedPlanId === segment.plan.planId; const labelVisible = segment.width > 92; return ( onSelectPlan(segment.plan.planId)} > {segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "} {formatDateTime(segment.plan.endTime)} {selected && } {labelVisible && ( {segment.plan.kppId} · {segment.plan.status} )} ); })} )}
); } function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) { const y1 = from.centerY; const y2 = to.centerY; const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4; const x2 = TIMELINE_LABEL_WIDTH + to.x - 6; const mid = x1 + Math.max(18, (x2 - x1) / 2); const path = x2 > x1 ? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}` : `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`; return ; } function formatTickDate(ms: number): string { return formatUtcDate(ms); } function formatTickTime(ms: number): string { return formatUtcTime(ms); } function formatDateTime(value: string): string { return formatUtcDateTime(parseUtc(value)); }