SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
18.7 KB · 363 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";5import type { FacilityStatus } from "@dci/core";6import { fmtCompact, fmtInt } from "@/lib/format";7import { STATUS_ORDER, STATUS_SHORT, statusVar } from "@/lib/labels";8import { cn } from "./cn";9import { StatusDot } from "./status-dot";1011/* ------------------------------------------------------------------------------------------12   SVG charts — thin marks, one axis, recessive grid, labels in text tokens, ResizeObserver13   for width, tooltips via hover layer. No chart library.14   ------------------------------------------------------------------------------------------ */1516function useWidth<T extends HTMLElement>(initial = 320): [React.RefObject<T | null>, number] {17  const ref = useRef<T | null>(null);18  const [w, setW] = useState(initial);19  useEffect(() => {20    const el = ref.current;21    if (!el) return;22    const ro = new ResizeObserver((entries) => {23      const cw = entries[0]?.contentRect.width;24      if (cw && cw > 0) setW(cw);25    });26    ro.observe(el);27    setW(el.clientWidth || initial);28    return () => ro.disconnect();29  }, [initial]);30  return [ref, w];31}3233const niceTicks = (max: number, n = 4): number[] => {34  if (max <= 0) return [0];35  const raw = max / n;36  const mag = 10 ** Math.floor(Math.log10(raw));37  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;38  const out: number[] = [];39  for (let v = 0; v <= max + 1e-9; v += step) out.push(Number(v.toFixed(6)));40  return out;41};4243/* ---------------------------------------------------------------- Sparkline */44export function Sparkline({ values, width = 96, height = 24, className, stroke = "var(--ink-2)", fill, title }: { values: Array<number | null>; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; title?: string }) {45  const pts = values.map((v, i) => ({ i, v })).filter((p): p is { i: number; v: number } => typeof p.v === "number" && Number.isFinite(p.v));46  if (pts.length < 2) return <span className={cn("inline-block", className)} style={{ width, height }} aria-hidden />;47  const max = Math.max(...pts.map((p) => p.v));48  const min = Math.min(...pts.map((p) => p.v));49  const span = max - min || 1;50  const x = (i: number) => (i / (values.length - 1)) * (width - 2) + 1;51  const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4);52  const d = pts.map((p, k) => `${k === 0 ? "M" : "L"}${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`).join(" ");53  const last = pts[pts.length - 1]!;54  return (55    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn("inline-block overflow-visible", className)} role="img" aria-label={title ?? "trend"}>56      {fill && <path d={`${d} L${x(last.i).toFixed(1)},${height} L${x(pts[0]!.i).toFixed(1)},${height} Z`} fill={stroke} opacity={0.08} />}57      <path d={d} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />58      <circle cx={x(last.i)} cy={y(last.v)} r={2} fill={stroke} />59    </svg>60  );61}6263/* ---------------------------------------------------------------- BarList */64export interface BarListItem {65  label: ReactNode;66  value: number | null;67  href?: string;68  secondary?: ReactNode;69  /** 0..1 coverage → hatch hint when low */70  coverage?: number | null;71  rank?: number;72  /** preformatted value (lets server components avoid passing formatter functions) */73  display?: string;74}7576/** Horizontal ranked bars (rankings module). Value formatted by `format`. */77export function BarList({ items, format = fmtCompact, max, className, showRank = true, barColor = "var(--ink-2)" }: { items: BarListItem[]; format?: (v: number | null) => string; max?: number; className?: string; showRank?: boolean; barColor?: string }) {78  const m = max ?? Math.max(1, ...items.map((i) => i.value ?? 0));79  return (80    <ol className={cn("flex flex-col", className)}>81      {items.map((it, i) => {82        const pct = it.value === null || it.value === undefined ? 0 : Math.max(1.5, (it.value / m) * 100);83        const row = (84          <div className="grid min-h-10 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 py-[5px] sm:min-h-0">85            {showRank ? <span className="figure w-5 text-right text-[11px] text-ink-4">{it.rank ?? i + 1}</span> : <span />}86            <div className="min-w-0">87              <div className="flex items-baseline justify-between gap-2">88                <span className="truncate text-[13px] text-ink group-hover:text-accent">{it.label}</span>89                {it.secondary && <span className="hidden shrink-0 text-[11px] text-ink-3 sm:inline">{it.secondary}</span>}90              </div>91              <div className="mt-1 h-[3px] w-full bg-surface-3">92                <div className="h-full" style={{ width: `${pct}%`, background: barColor, opacity: it.coverage !== undefined && it.coverage !== null && it.coverage < 0.5 ? 0.55 : 1 }} />93              </div>94            </div>95            <span className="figure w-[64px] text-right text-[12.5px] text-ink-2">{it.display ?? format(it.value)}</span>96          </div>97        );98        return (99          <li key={i} className="group border-b border-hair last:border-b-0">100            {it.href ? (101              <Link href={it.href} className="block">102                {row}103              </Link>104            ) : (105              row106            )}107          </li>108        );109      })}110    </ol>111  );112}113114/* ---------------------------------------------------------------- StackedBar (status distribution) */115export function StackedBar({ data, className, height = 10, legend = true, total, format = fmtInt }: { data: Partial<Record<FacilityStatus, number>>; className?: string; height?: number; legend?: boolean; total?: number; format?: (v: number | null) => string }) {116  const rows = STATUS_ORDER.map((s) => ({ s, v: data[s] ?? 0 })).filter((r) => r.v > 0);117  const sum = total ?? rows.reduce((a, r) => a + r.v, 0);118  if (!sum) return <div className={cn("h-[10px] w-full bg-surface-3", className)} aria-hidden />;119  return (120    <div className={className}>121      <div className="flex w-full gap-[2px]" style={{ height }} role="img" aria-label={rows.map((r) => `${STATUS_SHORT[r.s]} ${format(r.v)}`).join(", ")}>122        {rows.map((r) => (123          <div key={r.s} title={`${STATUS_SHORT[r.s]}: ${format(r.v)}`} style={{ width: `${(r.v / sum) * 100}%`, background: statusVar(r.s), minWidth: 2 }} className={cn(r.s === "rumored" && "opacity-60")} />124        ))}125      </div>126      {legend && (127        <ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-ink-2">128          {rows.map((r) => (129            <li key={r.s} className="inline-flex items-center gap-1.5">130              <StatusDot status={r.s} size={7} />131              <span>{STATUS_SHORT[r.s]}</span>132              <span className="figure text-ink-3">{format(r.v)}</span>133              <span className="figure text-ink-4">{Math.round((r.v / sum) * 100)}%</span>134            </li>135          ))}136        </ul>137      )}138    </div>139  );140}141142/* ---------------------------------------------------------------- Column bars (pipeline by year) */143export interface ColumnDatum {144  label: string;145  value: number | null;146  secondary?: number | null;147}148149export function ColumnChart({ data, height = 140, className, format = fmtCompact, color = "var(--accent)", unit, ariaLabel }: { data: ColumnDatum[]; height?: number; className?: string; format?: (v: number | null) => string; color?: string; unit?: string; ariaLabel?: string }) {150  const [ref, width] = useWidth<HTMLDivElement>();151  const [hover, setHover] = useState<number | null>(null);152  const pad = { l: 34, r: 4, t: 8, b: 20 };153  const max = Math.max(1, ...data.map((d) => d.value ?? 0));154  const ticks = niceTicks(max);155  const top = ticks[ticks.length - 1] ?? max;156  const iw = Math.max(10, width - pad.l - pad.r);157  const ih = height - pad.t - pad.b;158  const bw = data.length ? iw / data.length : iw;159  const y = (v: number) => pad.t + ih - (v / top) * ih;160  const desc = data.map((d) => `${d.label} ${format(d.value)}`).join(", ");161  return (162    <div ref={ref} className={cn("relative w-full", className)}>163      <svg width={width} height={height} role="img" aria-label={ariaLabel ?? desc} className="block overflow-visible">164        {ticks.map((t) => (165          <g key={t}>166            <line x1={pad.l} x2={width - pad.r} y1={y(t)} y2={y(t)} stroke="var(--hair)" strokeWidth={1} />167            <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-4)" className="figure">168              {fmtCompact(t, 0)}169            </text>170          </g>171        ))}172        {data.map((d, i) => {173          const v = d.value ?? 0;174          const x = pad.l + i * bw + bw * 0.18;175          const w = Math.max(2, bw * 0.64);176          const h = Math.max(v > 0 ? 2 : 0, y(0) - y(v));177          const active = hover === i;178          return (179            <g key={i} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} onTouchStart={() => setHover(i)}>180              <rect x={pad.l + i * bw} y={pad.t} width={bw} height={ih} fill="transparent" />181              <rect x={x} y={y(0) - h} width={w} height={h} fill={color} opacity={hover === null || active ? 1 : 0.5} rx={1} />182              {(data.length <= 8 || i % Math.ceil(data.length / 8) === 0) && (183                <text x={pad.l + i * bw + bw / 2} y={height - 6} textAnchor="middle" fontSize={10.5} fill="var(--ink-3)" className="figure">184                  {d.label}185                </text>186              )}187            </g>188          );189        })}190        <line x1={pad.l} x2={width - pad.r} y1={y(0)} y2={y(0)} stroke="var(--hair-2)" />191      </svg>192      {hover !== null && data[hover] && (193        <div className="pointer-events-none absolute -top-1 left-1/2 -translate-x-1/2 -translate-y-full rounded-xs border border-hair-2 bg-surface px-2 py-1 text-[11.5px]">194          <span className="text-ink-3">{data[hover].label}</span> <span className="figure font-medium">{format(data[hover].value)}</span>195          {unit && <span className="text-ink-3"> {unit}</span>}196        </div>197      )}198    </div>199  );200}201202/* ---------------------------------------------------------------- Line / Area chart */203export interface SeriesPoint {204  x: number | string;205  y: number | null;206}207export interface Series {208  name: string;209  data: SeriesPoint[];210  color?: string;211  area?: boolean;212  dashed?: boolean;213  /** step (horizontal-then-vertical) segments — for observed values that hold until the next observation */214  step?: boolean;215}216217/**218 * Single-axis line/area chart. ≥ 2 series → legend rendered. Crosshair + tooltip on hover.219 * `x` values are treated as ordinal categories (years).220 */221export function LineChart({ series, height = 180, className, format = fmtCompact, unit, ariaLabel, yLabel }: { series: Series[]; height?: number; className?: string; format?: (v: number | null) => string; unit?: string; ariaLabel?: string; yLabel?: string }) {222  const [ref, width] = useWidth<HTMLDivElement>();223  const [hover, setHover] = useState<number | null>(null);224  const id = useId();225  const xs = useMemo(() => Array.from(new Set(series.flatMap((s) => s.data.map((p) => String(p.x))))), [series]);226  const pad = { l: 38, r: 8, t: 10, b: 22 };227  const max = Math.max(1, ...series.flatMap((s) => s.data.map((p) => p.y ?? 0)));228  const ticks = niceTicks(max);229  const top = ticks[ticks.length - 1] ?? max;230  const iw = Math.max(10, width - pad.l - pad.r);231  const ih = height - pad.t - pad.b;232  const xAt = (i: number) => pad.l + (xs.length > 1 ? (i / (xs.length - 1)) * iw : iw / 2);233  const yAt = (v: number) => pad.t + ih - (v / top) * ih;234  const colors = ["var(--ink)", "var(--accent)", "var(--st-planned)", "var(--st-permitting)"];235236  const onMove = (e: React.MouseEvent<SVGSVGElement> | React.TouchEvent<SVGSVGElement>) => {237    const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();238    const cx = ("touches" in e ? e.touches[0]?.clientX ?? 0 : e.clientX) - rect.left;239    if (xs.length < 2) return setHover(0);240    const i = Math.round(((cx - pad.l) / iw) * (xs.length - 1));241    setHover(Math.max(0, Math.min(xs.length - 1, i)));242  };243244  const everyN = Math.max(1, Math.ceil(xs.length / Math.max(2, Math.floor(iw / 56))));245246  return (247    <div ref={ref} className={cn("relative w-full", className)}>248      <svg width={width} height={height} role="img" aria-label={ariaLabel ?? `${series.map((s) => s.name).join(", ")} over ${xs[0]}–${xs[xs.length - 1]}`} className="block touch-pan-y overflow-visible" onMouseMove={onMove} onMouseLeave={() => setHover(null)} onTouchStart={onMove} onTouchMove={onMove} onTouchEnd={() => setHover(null)}>249        <defs>250          {series.map((s, si) => (251            <linearGradient key={si} id={`${id}-g${si}`} x1="0" x2="0" y1="0" y2="1">252              <stop offset="0%" stopColor={s.color ?? colors[si % colors.length]} stopOpacity={0.18} />253              <stop offset="100%" stopColor={s.color ?? colors[si % colors.length]} stopOpacity={0} />254            </linearGradient>255          ))}256        </defs>257        {ticks.map((t) => (258          <g key={t}>259            <line x1={pad.l} x2={width - pad.r} y1={yAt(t)} y2={yAt(t)} stroke="var(--hair)" />260            <text x={pad.l - 6} y={yAt(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-4)" className="figure">261              {fmtCompact(t, 0)}262            </text>263          </g>264        ))}265        {xs.map((x, i) => (i % everyN === 0 || i === xs.length - 1) && (266          <text key={x} x={xAt(i)} y={height - 6} textAnchor={i === 0 ? "start" : i === xs.length - 1 ? "end" : "middle"} fontSize={10.5} fill="var(--ink-3)" className="figure">267            {x}268          </text>269        ))}270        {series.map((s, si) => {271          const color = s.color ?? colors[si % colors.length]!;272          const pts = xs.map((x, i) => ({ i, y: s.data.find((p) => String(p.x) === x)?.y ?? null })).filter((p): p is { i: number; y: number } => p.y !== null);273          if (!pts.length) return null;274          const d = pts275            .map((p, k) => {276              if (k === 0) return `M${xAt(p.i).toFixed(1)},${yAt(p.y).toFixed(1)}`;277              if (s.step) return `H${xAt(p.i).toFixed(1)} V${yAt(p.y).toFixed(1)}`;278              return `L${xAt(p.i).toFixed(1)},${yAt(p.y).toFixed(1)}`;279            })280            .join(" ");281          const area = `${d} L${xAt(pts[pts.length - 1]!.i).toFixed(1)},${yAt(0)} L${xAt(pts[0]!.i).toFixed(1)},${yAt(0)} Z`;282          return (283            <g key={si}>284              {s.area && <path d={area} fill={`url(#${id}-g${si})`} />}285              <path d={d} fill="none" stroke={color} strokeWidth={1.75} strokeDasharray={s.dashed ? "4 3" : undefined} strokeLinejoin="round" strokeLinecap="round" />286              {hover !== null && pts.find((p) => p.i === hover) && <circle cx={xAt(hover)} cy={yAt(pts.find((p) => p.i === hover)!.y)} r={3.5} fill={color} stroke="var(--surface)" strokeWidth={2} />}287            </g>288          );289        })}290        {hover !== null && <line x1={xAt(hover)} x2={xAt(hover)} y1={pad.t} y2={pad.t + ih} stroke="var(--ink-3)" strokeDasharray="2 3" />}291        <line x1={pad.l} x2={width - pad.r} y1={yAt(0)} y2={yAt(0)} stroke="var(--hair-2)" />292      </svg>293      {yLabel && <div className="label-caps absolute left-0 top-0 -translate-y-1/2">{yLabel}</div>}294      {hover !== null && (295        <div className="pointer-events-none absolute top-0 rounded-xs border border-hair-2 bg-surface px-2 py-1.5 text-[11.5px]" style={{ left: Math.min(Math.max(0, xAt(hover) - 60), Math.max(0, width - 140)) }}>296          <div className="figure text-ink-3">{xs[hover]}</div>297          {series.map((s, si) => {298            const v = s.data.find((p) => String(p.x) === xs[hover])?.y ?? null;299            return (300              <div key={si} className="flex items-center gap-1.5">301                <span className="inline-block size-2 rounded-full" style={{ background: s.color ?? colors[si % colors.length] }} />302                <span className="text-ink-2">{s.name}</span>303                <span className="figure ml-auto pl-3 font-medium">{format(v)}</span>304                {unit && <span className="text-ink-3">{unit}</span>}305              </div>306            );307          })}308        </div>309      )}310      {series.length >= 2 && (311        <ul className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-ink-2">312          {series.map((s, si) => (313            <li key={si} className="inline-flex items-center gap-1.5">314              <span className="inline-block h-[2px] w-3" style={{ background: s.color ?? colors[si % colors.length], borderTop: s.dashed ? "2px dashed" : undefined }} />315              {s.name}316            </li>317          ))}318        </ul>319      )}320    </div>321  );322}323324export function AreaChart(props: Parameters<typeof LineChart>[0]) {325  return <LineChart {...props} series={props.series.map((s) => ({ ...s, area: s.area ?? true }))} />;326}327328/* ---------------------------------------------------------------- Donut (small) */329export function Donut({ segments, size = 64, thickness = 8, className, label, ariaLabel }: { segments: Array<{ value: number; color: string; label: string }>; size?: number; thickness?: number; className?: string; label?: ReactNode; ariaLabel?: string }) {330  const total = segments.reduce((a, s) => a + s.value, 0) || 1;331  const r = (size - thickness) / 2;332  const c = 2 * Math.PI * r;333  const offsets = segments.reduce<number[]>((acc, s, i) => {334    acc.push(i === 0 ? 0 : acc[i - 1]! + (segments[i - 1]!.value / total) * c);335    return acc;336  }, []);337  return (338    <div className={cn("inline-flex items-center gap-3", className)}>339      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={ariaLabel ?? segments.map((s) => `${s.label} ${Math.round((s.value / total) * 100)}%`).join(", ")}>340        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-3)" strokeWidth={thickness} />341        {segments.map((s, i) => {342          const len = (s.value / total) * c;343          return <circle key={i} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={s.color} strokeWidth={thickness} strokeDasharray={`${Math.max(0, len - 2)} ${c - Math.max(0, len - 2)}`} strokeDashoffset={-(offsets[i] ?? 0)} transform={`rotate(-90 ${size / 2} ${size / 2})`} />;344        })}345        {label && (346          <text x="50%" y="50%" textAnchor="middle" dominantBaseline="central" fontSize={size / 5} fill="var(--ink)" className="figure">347            {label}348          </text>349        )}350      </svg>351      <ul className="flex flex-col gap-0.5 text-[11.5px] text-ink-2">352        {segments.map((s, i) => (353          <li key={i} className="inline-flex items-center gap-1.5">354            <span className="inline-block size-2 rounded-full" style={{ background: s.color }} />355            {s.label}356            <span className="figure text-ink-3">{Math.round((s.value / total) * 100)}%</span>357          </li>358        ))}359      </ul>360    </div>361  );362}363