SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
7.1 KB · 137 lines tsx
Raw Blame History
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