SPB Git

spb/rareindex

Public
10.9 KB · 264 lines tsx
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, 4);109  const sx = (x: number) => pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw);110  const sy = (y: number) => pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih;111  const span = xMax - xMin;112  const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.floor(iw / 110)));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 tooltipLeft = hover !== null ? Math.min(Math.max(hover + 12, pad.left), W - 200) : 0;138139  return (140    <div ref={ref} className={`relative ${className ?? ''}`}>141      {prepared.length > 1 ? (142        <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">143          {prepared.map((s) => (144            <li key={s.id} className="flex items-center gap-1.5">145              <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)` } : {}) }} />146              {s.label}147            </li>148          ))}149        </ul>150      ) : null}151      <svg152        width={W}153        height={H}154        viewBox={`0 0 ${W} ${H}`}155        role="img"156        aria-label={ariaLabel}157        className="block select-none"158        onMouseMove={(e) => {159          const rect = e.currentTarget.getBoundingClientRect();160          const x = e.clientX - rect.left;161          setHover(x >= pad.left && x <= pad.left + iw ? x : null);162        }}163        onMouseLeave={() => setHover(null)}164        onTouchMove={(e) => {165          const rect = e.currentTarget.getBoundingClientRect();166          const t = e.touches[0];167          if (!t) return;168          const x = t.clientX - rect.left;169          setHover(x >= pad.left && x <= pad.left + iw ? x : null);170        }}171        onTouchEnd={() => setHover(null)}172      >173        <defs>174          {prepared.map((s) => (175            <linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">176              <stop offset="0%" stopColor={s.color} stopOpacity={0.14} />177              <stop offset="100%" stopColor={s.color} stopOpacity={0.02} />178            </linearGradient>179          ))}180        </defs>181        {ticks.map((t) => (182          <g key={t}>183            <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />184            <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">185              {fmtY(t)}186            </text>187          </g>188        ))}189        {xTicks.map((t) => (190          <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">191            {fmtDay(t, span)}192          </text>193        ))}194        {reference ? (195          <g>196            <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" />197            <text x={pad.left + 4} y={sy(indexed ? 0 : reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">198              {reference.label}199            </text>200          </g>201        ) : null}202        {prepared.map((s) => {203          const kind = s.kind ?? 'line';204          if (kind === 'dots') {205            return (206              <g key={s.id}>207                {s.pts.map((p, i) => (208                  <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} />209                ))}210              </g>211            );212          }213          const d = linePath(s.pts, kind === 'step');214          const last = s.pts[s.pts.length - 1]!;215          return (216            <g key={s.id}>217              {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}218              <path d={d} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '4 4' : undefined} />219              <circle cx={sx(last.x)} cy={sy(last.y)} r={4} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />220            </g>221          );222        })}223        {hover !== null && hoverDate !== null ? (224          <g>225            <line x1={sx(hoverDate)} x2={sx(hoverDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} />226            {hoverRows.map(({ s, p }) => (227              <circle key={s.id} cx={sx(p.x)} cy={sy(p.y)} r={5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />228            ))}229          </g>230        ) : null}231      </svg>232      {hover !== null && hoverDate !== null ? (233        <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 }}>234          <div className="mb-1 font-medium text-fg">{fmtDayFull(hoverDate)}</div>235          {hoverRows.map(({ s, p }) => (236            <div key={s.id} className="flex items-center justify-between gap-3">237              <span className="flex items-center gap-1.5 text-muted">238                <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />239                {s.label}240              </span>241              <span className="num font-medium text-fg">{fmtYFull(p.y)}</span>242            </div>243          ))}244          {hoverRows[0]?.p.meta ? <div className="mt-1 text-subtle">{hoverRows[0].p.meta}</div> : null}245        </div>246      ) : null}247      {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null}248    </div>249  );250}251252function niceTimeTicks(min: number, max: number, count: number): number[] {253  if (max <= min) return [min];254  const span = max - min;255  const day = 86_400_000;256  const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day];257  const step = candidates.find((c) => span / c <= count) ?? candidates[candidates.length - 1]!;258  const out: number[] = [];259  const start = Math.ceil(min / step) * step;260  for (let t = start; t <= max; t += step) out.push(t);261  if (!out.length) out.push(min, max);262  return out;263}264
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme