SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.8 KB · 286 lines tsx
Raw Blame History
1'use client';23import { useEffect, useId, useMemo, useRef, useState } from 'react';4import { SERIES_COLORS, compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale';56export interface SeriesPoint {7  x: string; // ISO date (YYYY-MM-DD) or ISO datetime8  y: number;9  meta?: string;10}1112export interface Series {13  id: string;14  label: string;15  points: SeriesPoint[];16  color?: string;17  kind?: 'line' | 'area' | 'dots' | 'step';18  /** dashed style for benchmarks/references */19  dashed?: boolean;20}2122export interface LineChartProps {23  series: Series[];24  height?: number;25  currency?: boolean;26  /** show values as % change from first point of each series (indexed comparison) */27  indexed?: boolean;28  yLabel?: string;29  className?: string;30  ariaLabel: string;31  /** optional horizontal reference line (e.g. RIV) */32  reference?: { value: number; label: string } | null;33  emptyLabel?: string;34}3536/**37 * Responsive SVG line/area/dot chart following the dataviz spec: 2px lines, hairline solid grid,38 * ≥8px end markers with a surface ring, crosshair + tooltip on hover, legend for ≥2 series,39 * selective end labels. Fixed categorical colour order; text uses text tokens only.40 */41export function LineChart({ series, height = 260, currency = false, indexed = false, yLabel, className, ariaLabel, reference = null, emptyLabel = 'Not enough data to chart' }: LineChartProps) {42  const ref = useRef<HTMLDivElement>(null);43  const [width, setWidth] = useState(640);44  const [hover, setHover] = useState<number | null>(null);45  const uid = useId();4647  useEffect(() => {48    const el = ref.current;49    if (!el) return;50    const ro = new ResizeObserver((entries) => {51      for (const e of entries) setWidth(Math.max(240, Math.floor(e.contentRect.width)));52    });53    ro.observe(el);54    return () => ro.disconnect();55  }, []);5657  const prepared = useMemo(() => {58    const s = series59      .map((ser, i) => {60        const pts = ser.points61          .map((p) => ({ x: parseDay(p.x), y: p.y, meta: p.meta }))62          .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))63          .sort((a, b) => a.x - b.x);64        let out = pts;65        if (indexed && pts.length && pts[0]!.y !== 0) {66          const base = pts[0]!.y;67          out = pts.map((p) => ({ ...p, y: (p.y / base - 1) * 100 }));68        }69        return { ...ser, color: ser.color ?? SERIES_COLORS[i % SERIES_COLORS.length]!, pts: out };70      })71      .filter((s) => s.pts.length > 0);72    return s;73  }, [series, indexed]);7475  const allPts = prepared.flatMap((s) => s.pts);76  const hasLine = prepared.some((s) => (s.kind ?? 'line') !== 'dots' && s.pts.length >= 2);77  if (allPts.length === 0 || (!hasLine && allPts.length < 1)) {78    return (79      <div ref={ref} className={className} style={{ height }}>80        <div className="flex h-full items-center justify-center text-xs text-subtle">{emptyLabel}</div>81      </div>82    );83  }8485  const pad = { top: 12, right: 56, bottom: 26, left: 8 };86  const W = width;87  const H = height;88  const iw = W - pad.left - pad.right;89  const ih = H - pad.top - pad.bottom;90  const xs = allPts.map((p) => p.x);91  const ys = allPts.map((p) => p.y).concat(reference ? [indexed ? 0 : reference.value] : []);92  let xMin = Math.min(...xs);93  let xMax = Math.max(...xs);94  if (xMax - xMin < 7 * 86_400_000) {95    // pad a degenerate/short time range so single points and short runs stay readable96    xMin -= 15 * 86_400_000;97    xMax += 15 * 86_400_000;98  }99  let yMin = Math.min(...ys);100  let yMax = Math.max(...ys);101  if (yMin === yMax) {102    yMin -= Math.abs(yMin) * 0.05 || 1;103    yMax += Math.abs(yMax) * 0.05 || 1;104  }105  const yPad = (yMax - yMin) * 0.08;106  yMin -= yPad;107  yMax += yPad;108  const ticks = niceTicks(yMin, yMax, W < 480 ? 3 : 4);109  const sx = (x: number) => Math.round((pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw)) * 100) / 100;110  const sy = (y: number) => Math.round((pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih) * 100) / 100;111  const span = xMax - xMin;112  const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 100))));113  const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency }));114  const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency }));115116  // hover: nearest x across all series117  const hoverX = hover === null ? null : xMin + ((hover - pad.left) / iw) * (xMax - xMin);118  const hoverRows = hoverX === null119    ? []120    : prepared.map((s) => {121        let best = s.pts[0]!;122        let bd = Infinity;123        for (const p of s.pts) {124          const d = Math.abs(p.x - hoverX);125          if (d < bd) {126            bd = d;127            best = p;128          }129        }130        return { s, p: best };131      });132  const hoverDate = hoverRows.length ? hoverRows.reduce((a, r) => (Math.abs(r.p.x - hoverX!) < Math.abs(a - hoverX!) ? r.p.x : a), hoverRows[0]!.p.x) : null;133134  const linePath = (pts: Array<{ x: number; y: number }>, step = false) =>135    pts.map((p, i) => (i === 0 ? `M${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}` : step ? `H${sx(p.x).toFixed(1)}V${sy(p.y).toFixed(1)}` : `L${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`)).join(' ');136137  const narrow = W < 560;138  const tooltipLeft = hover !== null ? (narrow ? pad.left : Math.min(Math.max(hover + 12, pad.left), W - 200)) : 0;139140  return (141    <div ref={ref} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}>142      {prepared.length > 1 ? (143        <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">144          {prepared.map((s) => (145            <li key={s.id} className="flex items-center gap-1.5">146              <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.dashed ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />147              {s.label}148            </li>149          ))}150        </ul>151      ) : null}152      <svg153        width="100%"154        height={H}155        viewBox={`0 0 ${W} ${H}`}156        preserveAspectRatio="none"157        role="img"158        aria-label={ariaLabel}159        className="block max-w-full select-none"160        style={{ touchAction: 'pan-y' }}161        onPointerDown={(e) => {162          const rect = e.currentTarget.getBoundingClientRect();163          const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;164          setHover(x >= pad.left && x <= pad.left + iw ? x : null);165        }}166        onPointerMove={(e) => {167          if (e.pointerType !== 'mouse' && e.buttons === 0) return;168          const rect = e.currentTarget.getBoundingClientRect();169          const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;170          setHover(x >= pad.left && x <= pad.left + iw ? x : null);171        }}172        onPointerLeave={(e) => {173          if (e.pointerType === 'mouse') setHover(null);174        }}175      >176        <defs>177          {prepared.map((s) => (178            <linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">179              <stop offset="0%" stopColor={s.color} stopOpacity={0.14} />180              <stop offset="100%" stopColor={s.color} stopOpacity={0.02} />181            </linearGradient>182          ))}183        </defs>184        {ticks.map((t) => (185          <g key={t}>186            <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />187            <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">188              {fmtY(t)}189            </text>190          </g>191        ))}192        {xTicks.map((t) => (193          <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">194            {fmtDay(t, span)}195          </text>196        ))}197        {reference ? (198          <g>199            <line x1={pad.left} x2={pad.left + iw} y1={sy(indexed ? 0 : reference.value)} y2={sy(indexed ? 0 : reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="3 3" />200            <text x={pad.left + 4} y={sy(indexed ? 0 : reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">201              {reference.label}202            </text>203          </g>204        ) : null}205        {prepared.map((s) => {206          const kind = s.kind ?? 'line';207          if (kind === 'dots') {208            return (209              <g key={s.id}>210                {s.pts.map((p, i) => (211                  <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={3.5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={1.5} opacity={0.85} />212                ))}213              </g>214            );215          }216          const d = linePath(s.pts, kind === 'step');217          const last = s.pts[s.pts.length - 1]!;218          return (219            <g key={s.id}>220              {kind === 'area' ? <path d={`${d} L${sx(last.x).toFixed(1)},${(pad.top + ih).toFixed(1)} L${sx(s.pts[0]!.x).toFixed(1)},${(pad.top + ih).toFixed(1)} Z`} fill={`url(#${uid}-g-${s.id})`} /> : null}221              <path d={d} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '4 4' : undefined} />222              <circle cx={sx(last.x)} cy={sy(last.y)} r={4} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />223            </g>224          );225        })}226        {hover !== null && hoverDate !== null ? (227          <g>228            <line x1={sx(hoverDate)} x2={sx(hoverDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} />229            {hoverRows.map(({ s, p }) => (230              <circle key={s.id} cx={sx(p.x)} cy={sy(p.y)} r={5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />231            ))}232          </g>233        ) : null}234      </svg>235      {hover !== null && hoverDate !== null ? (236        <div className="pointer-events-none absolute top-1 z-10 min-w-[160px] rounded-md border border-border bg-elevated px-2.5 py-2 text-[11px] shadow-pop" style={{ left: tooltipLeft }}>237          <div className="mb-1 font-medium text-fg">{fmtDayFull(hoverDate)}</div>238          {hoverRows.map(({ s, p }) => (239            <div key={s.id} className="flex items-center justify-between gap-3">240              <span className="flex items-center gap-1.5 text-muted">241                <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />242                {s.label}243              </span>244              <span className="num font-medium text-fg">{fmtYFull(p.y)}</span>245            </div>246          ))}247          {hoverRows[0]?.p.meta ? <div className="mt-1 text-subtle">{hoverRows[0].p.meta}</div> : null}248        </div>249      ) : null}250      {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null}251      <table className="sr-only">252        <caption>{ariaLabel}</caption>253        <thead>254          <tr>255            <th>Series</th>256            <th>Date</th>257            <th>Value</th>258          </tr>259        </thead>260        <tbody>261          {prepared.flatMap((s) => s.pts.slice(-120).map((p, i) => (262            <tr key={`${s.id}-${i}`}>263              <td>{s.label}</td>264              <td>{fmtDayFull(p.x)}</td>265              <td>{fmtYFull(p.y)}</td>266            </tr>267          )))}268        </tbody>269      </table>270    </div>271  );272}273274function niceTimeTicks(min: number, max: number, count: number): number[] {275  if (max <= min) return [min];276  const span = max - min;277  const day = 86_400_000;278  const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day];279  const step = candidates.find((c) => span / c <= count) ?? candidates[candidates.length - 1]!;280  const out: number[] = [];281  const start = Math.ceil(min / step) * step;282  for (let t = start; t <= max; t += step) out.push(t);283  if (!out.length) out.push(min, max);284  return out;285}286