import { useLayoutEffect, useMemo, useRef, useState } from "react"; import type { TimelineRange, TimelineRow } from "../../model/timelineTypes"; import { 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 { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime"; export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; // Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён, // не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная // ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция, // проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые // строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей». // На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из // его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED. const DEAD_LINK_STATUSES: ReadonlySet = new Set([ "SUPERSEDED", "REJECTED", "LAYIN_FAILED", "NOT_ACCEPTED", "START_AMBIGUOUS" ]); 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); // basePlanId выбранного плана — чтобы подсветить его прошлое/будущее звено по РЕАЛЬНОЙ связи // (а не по времени): предыдущий = план с этим planId, следующий = план с basePlanId == выбранный. const selectedBasePlanId = selectedPlanId ? segments.find((segment) => segment.plan.planId === selectedPlanId)?.plan.basePlanId ?? undefined : undefined; 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} · {formatLocalDateTime(zrv.startMs)} – {formatLocalDateTime(zrv.endMs)} ); })} )} ); })} {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; // Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного; // следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени. // prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую. // next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота) // живым продолжением не подсвечиваем. const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status); const neighbor = selected ? undefined : selectedBasePlanId && segment.plan.planId === selectedBasePlanId ? "prev" : selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId ? "next" : undefined; const labelVisible = segment.width > 92; // При активном выборе все «непричастные» планы гасим до еле заметных, а текущий / // прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана. const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined; // Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение). const dismissed = Boolean(segment.plan.dismissedAt); return ( onSelectPlan(segment.plan.planId)} > {segment.plan.planId} · {segment.plan.status} {dismissed ? " · снят с учёта" : ""} {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} {neighbor && ( )} {selected && } {labelVisible && ( {segment.plan.kppId} · {segment.plan.status} )} ); })} )}
); } function formatTickDate(ms: number): string { return formatLocalDate(ms); } function formatTickTime(ms: number): string { return formatLocalTime(ms); } function formatDateTime(value: string): string { return formatLocalDateTime(parseLocal(value)); }