HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { scaleTime } from 'd3-scale';3import Link from 'next/link';4import { useMemo, useRef, useState } from 'react';5import { cn } from '@/lib/cn';67export type Lane = { key: string; label: string; color?: string };8export type LaneEvent = { id: string; lane: string; at: string | number | Date; importance?: number; label: string; href?: string; sub?: string };910const toT = (v: string | number | Date) => (v instanceof Date ? v.getTime() : typeof v === 'number' ? v : new Date(v).getTime());1112/**13 * Lanes × time: one row per lane, dots sized by importance (0–3), month ticks, hover tooltip, optional brushable range14 * (drag on the chart; `onRange(from, to)` fires on release; double-click clears). Reduced-motion safe (no animation).15 * Server renders nothing interactive — the SVG is static markup until hydration.16 */17export function TimelineLanes({18 lanes,19 events,20 from,21 to,22 height,23 brushable = false,24 onRange,25 className,26 laneWidth = 96,27 title = 'Timeline',28}: {29 lanes: Lane[];30 events: LaneEvent[];31 from?: string | number | Date;32 to?: string | number | Date;33 height?: number;34 brushable?: boolean;35 onRange?: (from: Date, to: Date) => void;36 className?: string;37 /** Width reserved for lane labels (viewBox units). */38 laneWidth?: number;39 title?: string;40}) {41 const w = 760;42 const rowH = 26;43 const pad = { l: laneWidth, r: 12, t: 8, b: 22 };44 const H = height ?? pad.t + pad.b + rowH * Math.max(1, lanes.length);45 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]);46 const [t0, t1] = useMemo(() => {47 const ts = evs.map((e) => e.t);48 const lo = from !== undefined ? toT(from) : Math.min(...ts);49 const hi = to !== undefined ? toT(to) : Math.max(...ts);50 return lo === hi || !Number.isFinite(lo) || !Number.isFinite(hi) ? [Date.now() - 365 * 86400000, Date.now()] : [lo, hi];51 }, [evs, from, to]);52 const x = useMemo(() => scaleTime().domain([new Date(t0), new Date(t1)]).range([pad.l, w - pad.r]), [t0, t1, pad.l]);53 const ticks = x.ticks(Math.min(12, Math.max(3, Math.round((t1 - t0) / (30 * 86400000)))));54 const [hover, setHover] = useState<string | null>(null);55 const [brush, setBrush] = useState<{ a: number; b: number } | null>(null);56 const dragging = useRef<number | null>(null);57 const svgRef = useRef<SVGSVGElement>(null);5859 const vx = (clientX: number) => {60 const rect = svgRef.current?.getBoundingClientRect();61 if (!rect) return 0;62 return Math.max(pad.l, Math.min(w - pad.r, ((clientX - rect.left) / rect.width) * w));63 };64 const onDown = (e: React.PointerEvent) => {65 if (!brushable) return;66 dragging.current = vx(e.clientX);67 setBrush({ a: dragging.current, b: dragging.current });68 };69 const onMove = (e: React.PointerEvent) => {70 if (!brushable || dragging.current === null) return;71 setBrush({ a: dragging.current, b: vx(e.clientX) });72 };73 const onUp = () => {74 if (!brushable || dragging.current === null) return;75 dragging.current = null;76 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)));77 else setBrush(null);78 };79 if (!lanes.length) return <p className={cn('text-xs text-ink-3', className)}>No lanes</p>;80 const active = hover ? evs.find((e) => e.id === hover) : null;81 const laneY = (key: string) => pad.t + rowH * lanes.findIndex((l) => l.key === key) + rowH / 2;82 const rad = (imp: number | undefined) => 2 + Math.max(0, Math.min(3, imp ?? 1)) * 1.1;8384 return (85 <div className={cn('relative', className)} data-timeline-lanes>86 <svg ref={svgRef} viewBox={`0 0 ${w} ${H}`} className={cn('block w-full select-none', brushable && 'cursor-crosshair touch-pan-y')} role="img" aria-label={title} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={() => { onUp(); setHover(null); }} onDoubleClick={() => setBrush(null)}>87 <title>{title}</title>88 {lanes.map((l, i) => (89 <g key={l.key}>90 <rect x={pad.l} y={pad.t + i * rowH} width={w - pad.l - pad.r} height={rowH} fill={i % 2 ? 'var(--surface-2)' : 'transparent'} opacity={0.6} />91 <line x1={pad.l} x2={w - pad.r} y1={pad.t + (i + 1) * rowH} y2={pad.t + (i + 1) * rowH} stroke="var(--rule)" />92 <text x={pad.l - 8} y={pad.t + i * rowH + rowH / 2 + 3.5} textAnchor="end" fontSize={10.5} fill="var(--ink-2)">93 {l.label.length > 14 ? `${l.label.slice(0, 13)}…` : l.label}94 </text>95 </g>96 ))}97 {ticks.map((t, i) => (98 <g key={i}>99 <line x1={x(t)} x2={x(t)} y1={pad.t} y2={H - pad.b} stroke="var(--rule)" strokeDasharray="2 3" />100 <text x={x(t)} y={H - 7} textAnchor="middle" fontSize={9.5} fill="var(--ink-3)">101 {t.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' })}102 </text>103 </g>104 ))}105 {brush && <rect x={Math.min(brush.a, brush.b)} y={pad.t} width={Math.abs(brush.b - brush.a)} height={H - pad.t - pad.b} fill="var(--accent-soft)" stroke="var(--accent)" strokeWidth={0.75} />}106 {evs.map((e) => {107 const lane = lanes.find((l) => l.key === e.lane);108 const c = lane?.color ?? 'var(--series-1)';109 const isH = hover === e.id;110 return (111 <circle key={e.id} cx={x(new Date(e.t))} cy={laneY(e.lane)} r={rad(e.importance) + (isH ? 1.5 : 0)} fill={c} fillOpacity={(e.importance ?? 1) >= 3 ? 0.95 : 0.65} stroke={isH ? 'var(--ink)' : 'none'} onPointerEnter={() => setHover(e.id)} onClick={(ev) => { ev.stopPropagation(); setHover(e.id); }}>112 <title>{`${e.label} — ${new Date(e.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}`}</title>113 </circle>114 );115 })}116 </svg>117 {active && (118 <div role="status" className="panel pointer-events-none absolute z-10 max-w-[16rem] px-2.5 py-1.5 text-xs shadow-lg" style={{ left: `min(${(x(new Date(active.t)) / w) * 100 + 1}%, calc(100% - 16rem))`, top: `${(laneY(active.lane) / H) * 100}%`, transform: 'translateY(-115%)' }}>119 <p className="tnum text-ink-3">{new Date(active.t).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}</p>120 <p className="truncate font-medium text-ink">{active.label}</p>121 {active.sub && <p className="truncate text-ink-3">{active.sub}</p>}122 </div>123 )}124 {brushable && brush && Math.abs(brush.a - brush.b) > 4 && (125 <p className="tnum mt-1 text-[11px] text-ink-3">126 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 clear127 </p>128 )}129 <ul className="sr-only">130 {evs.map((e) => (131 <li key={e.id}>{e.href ? <Link href={e.href}>{e.label}</Link> : e.label}</li>132 ))}133 </ul>134 </div>135 );136}137