'use client'; import { scaleTime } from 'd3-scale'; import Link from 'next/link'; import { useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; export type Lane = { key: string; label: string; color?: string }; export type LaneEvent = { id: string; lane: string; at: string | number | Date; importance?: number; label: string; href?: string; sub?: string }; const toT = (v: string | number | Date) => (v instanceof Date ? v.getTime() : typeof v === 'number' ? v : new Date(v).getTime()); /** * Lanes × time: one row per lane, dots sized by importance (0–3), month ticks, hover tooltip, optional brushable range * (drag on the chart; `onRange(from, to)` fires on release; double-click clears). Reduced-motion safe (no animation). * Server renders nothing interactive — the SVG is static markup until hydration. */ export function TimelineLanes({ lanes, events, from, to, height, brushable = false, onRange, className, laneWidth = 96, title = 'Timeline', }: { lanes: Lane[]; events: LaneEvent[]; from?: string | number | Date; to?: string | number | Date; height?: number; brushable?: boolean; onRange?: (from: Date, to: Date) => void; className?: string; /** Width reserved for lane labels (viewBox units). */ laneWidth?: number; title?: string; }) { const w = 760; const rowH = 26; const pad = { l: laneWidth, r: 12, t: 8, b: 22 }; const H = height ?? pad.t + pad.b + rowH * Math.max(1, lanes.length); const evs = useMemo(() => events.map((e) => ({ ...e, t: toT(e.at) })).filter((e) => Number.isFinite(e.t) && lanes.some((l) => l.key === e.lane)), [events, lanes]); const [t0, t1] = useMemo(() => { const ts = evs.map((e) => e.t); const lo = from !== undefined ? toT(from) : Math.min(...ts); const hi = to !== undefined ? toT(to) : Math.max(...ts); return lo === hi || !Number.isFinite(lo) || !Number.isFinite(hi) ? [Date.now() - 365 * 86400000, Date.now()] : [lo, hi]; }, [evs, from, to]); const x = useMemo(() => scaleTime().domain([new Date(t0), new Date(t1)]).range([pad.l, w - pad.r]), [t0, t1, pad.l]); const ticks = x.ticks(Math.min(12, Math.max(3, Math.round((t1 - t0) / (30 * 86400000))))); const [hover, setHover] = useState(null); const [brush, setBrush] = useState<{ a: number; b: number } | null>(null); const dragging = useRef(null); const svgRef = useRef(null); const vx = (clientX: number) => { const rect = svgRef.current?.getBoundingClientRect(); if (!rect) return 0; return Math.max(pad.l, Math.min(w - pad.r, ((clientX - rect.left) / rect.width) * w)); }; const onDown = (e: React.PointerEvent) => { if (!brushable) return; dragging.current = vx(e.clientX); setBrush({ a: dragging.current, b: dragging.current }); }; const onMove = (e: React.PointerEvent) => { if (!brushable || dragging.current === null) return; setBrush({ a: dragging.current, b: vx(e.clientX) }); }; const onUp = () => { if (!brushable || dragging.current === null) return; dragging.current = null; if (brush && Math.abs(brush.a - brush.b) > 4) onRange?.(x.invert(Math.min(brush.a, brush.b)), x.invert(Math.max(brush.a, brush.b))); else setBrush(null); }; if (!lanes.length) return

No lanes

; const active = hover ? evs.find((e) => e.id === hover) : null; const laneY = (key: string) => pad.t + rowH * lanes.findIndex((l) => l.key === key) + rowH / 2; const rad = (imp: number | undefined) => 2 + Math.max(0, Math.min(3, imp ?? 1)) * 1.1; return (
{ onUp(); setHover(null); }} onDoubleClick={() => setBrush(null)}> {title} {lanes.map((l, i) => ( {l.label.length > 14 ? `${l.label.slice(0, 13)}…` : l.label} ))} {ticks.map((t, i) => ( {t.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' })} ))} {brush && } {evs.map((e) => { const lane = lanes.find((l) => l.key === e.lane); const c = lane?.color ?? 'var(--series-1)'; const isH = hover === e.id; return ( = 3 ? 0.95 : 0.65} stroke={isH ? 'var(--ink)' : 'none'} onPointerEnter={() => setHover(e.id)} onClick={(ev) => { ev.stopPropagation(); setHover(e.id); }}> {`${e.label} — ${new Date(e.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}`} ); })} {active && (

{new Date(active.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}

{active.label}

{active.sub &&

{active.sub}

}
)} {brushable && brush && Math.abs(brush.a - brush.b) > 4 && (

Range: {x.invert(Math.min(brush.a, brush.b)).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })} → {x.invert(Math.max(brush.a, brush.b)).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })} · double-click to clear

)}
    {evs.map((e) => (
  • {e.href ? {e.label} : e.label}
  • ))}
); }