"use client"; import Link from "next/link"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import type { FacilityStatus } from "@dci/core"; import { fmtCompact, fmtInt } from "@/lib/format"; import { STATUS_ORDER, STATUS_SHORT, statusVar } from "@/lib/labels"; import { cn } from "./cn"; import { StatusDot } from "./status-dot"; /* ------------------------------------------------------------------------------------------ SVG charts — thin marks, one axis, recessive grid, labels in text tokens, ResizeObserver for width, tooltips via hover layer. No chart library. ------------------------------------------------------------------------------------------ */ function useWidth(initial = 320): [React.RefObject, number] { const ref = useRef(null); const [w, setW] = useState(initial); useEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver((entries) => { const cw = entries[0]?.contentRect.width; if (cw && cw > 0) setW(cw); }); ro.observe(el); setW(el.clientWidth || initial); return () => ro.disconnect(); }, [initial]); return [ref, w]; } const niceTicks = (max: number, n = 4): number[] => { if (max <= 0) return [0]; const raw = max / n; const mag = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw; const out: number[] = []; for (let v = 0; v <= max + 1e-9; v += step) out.push(Number(v.toFixed(6))); return out; }; /* ---------------------------------------------------------------- Sparkline */ export function Sparkline({ values, width = 96, height = 24, className, stroke = "var(--ink-2)", fill, title }: { values: Array; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; title?: string }) { const pts = values.map((v, i) => ({ i, v })).filter((p): p is { i: number; v: number } => typeof p.v === "number" && Number.isFinite(p.v)); if (pts.length < 2) return ; const max = Math.max(...pts.map((p) => p.v)); const min = Math.min(...pts.map((p) => p.v)); const span = max - min || 1; const x = (i: number) => (i / (values.length - 1)) * (width - 2) + 1; const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4); const d = pts.map((p, k) => `${k === 0 ? "M" : "L"}${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`).join(" "); const last = pts[pts.length - 1]!; return ( {fill && } ); } /* ---------------------------------------------------------------- BarList */ export interface BarListItem { label: ReactNode; value: number | null; href?: string; secondary?: ReactNode; /** 0..1 coverage → hatch hint when low */ coverage?: number | null; rank?: number; /** preformatted value (lets server components avoid passing formatter functions) */ display?: string; } /** Horizontal ranked bars (rankings module). Value formatted by `format`. */ export 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 }) { const m = max ?? Math.max(1, ...items.map((i) => i.value ?? 0)); return (
    {items.map((it, i) => { const pct = it.value === null || it.value === undefined ? 0 : Math.max(1.5, (it.value / m) * 100); const row = (
    {showRank ? {it.rank ?? i + 1} : }
    {it.label} {it.secondary && {it.secondary}}
    {it.display ?? format(it.value)}
    ); return (
  1. {it.href ? ( {row} ) : ( row )}
  2. ); })}
); } /* ---------------------------------------------------------------- StackedBar (status distribution) */ export function StackedBar({ data, className, height = 10, legend = true, total, format = fmtInt }: { data: Partial>; className?: string; height?: number; legend?: boolean; total?: number; format?: (v: number | null) => string }) { const rows = STATUS_ORDER.map((s) => ({ s, v: data[s] ?? 0 })).filter((r) => r.v > 0); const sum = total ?? rows.reduce((a, r) => a + r.v, 0); if (!sum) return
; return (
`${STATUS_SHORT[r.s]} ${format(r.v)}`).join(", ")}> {rows.map((r) => (
))}
{legend && (
    {rows.map((r) => (
  • {STATUS_SHORT[r.s]} {format(r.v)} {Math.round((r.v / sum) * 100)}%
  • ))}
)}
); } /* ---------------------------------------------------------------- Column bars (pipeline by year) */ export interface ColumnDatum { label: string; value: number | null; secondary?: number | null; } export 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 }) { const [ref, width] = useWidth(); const [hover, setHover] = useState(null); const pad = { l: 34, r: 4, t: 8, b: 20 }; const max = Math.max(1, ...data.map((d) => d.value ?? 0)); const ticks = niceTicks(max); const top = ticks[ticks.length - 1] ?? max; const iw = Math.max(10, width - pad.l - pad.r); const ih = height - pad.t - pad.b; const bw = data.length ? iw / data.length : iw; const y = (v: number) => pad.t + ih - (v / top) * ih; const desc = data.map((d) => `${d.label} ${format(d.value)}`).join(", "); return (
{ticks.map((t) => ( {fmtCompact(t, 0)} ))} {data.map((d, i) => { const v = d.value ?? 0; const x = pad.l + i * bw + bw * 0.18; const w = Math.max(2, bw * 0.64); const h = Math.max(v > 0 ? 2 : 0, y(0) - y(v)); const active = hover === i; return ( setHover(i)} onMouseLeave={() => setHover(null)} onTouchStart={() => setHover(i)}> {(data.length <= 8 || i % Math.ceil(data.length / 8) === 0) && ( {d.label} )} ); })} {hover !== null && data[hover] && (
{data[hover].label} {format(data[hover].value)} {unit && {unit}}
)}
); } /* ---------------------------------------------------------------- Line / Area chart */ export interface SeriesPoint { x: number | string; y: number | null; } export interface Series { name: string; data: SeriesPoint[]; color?: string; area?: boolean; dashed?: boolean; /** step (horizontal-then-vertical) segments — for observed values that hold until the next observation */ step?: boolean; } /** * Single-axis line/area chart. ≥ 2 series → legend rendered. Crosshair + tooltip on hover. * `x` values are treated as ordinal categories (years). */ export 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 }) { const [ref, width] = useWidth(); const [hover, setHover] = useState(null); const id = useId(); const xs = useMemo(() => Array.from(new Set(series.flatMap((s) => s.data.map((p) => String(p.x))))), [series]); const pad = { l: 38, r: 8, t: 10, b: 22 }; const max = Math.max(1, ...series.flatMap((s) => s.data.map((p) => p.y ?? 0))); const ticks = niceTicks(max); const top = ticks[ticks.length - 1] ?? max; const iw = Math.max(10, width - pad.l - pad.r); const ih = height - pad.t - pad.b; const xAt = (i: number) => pad.l + (xs.length > 1 ? (i / (xs.length - 1)) * iw : iw / 2); const yAt = (v: number) => pad.t + ih - (v / top) * ih; const colors = ["var(--ink)", "var(--accent)", "var(--st-planned)", "var(--st-permitting)"]; const onMove = (e: React.MouseEvent | React.TouchEvent) => { const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); const cx = ("touches" in e ? e.touches[0]?.clientX ?? 0 : e.clientX) - rect.left; if (xs.length < 2) return setHover(0); const i = Math.round(((cx - pad.l) / iw) * (xs.length - 1)); setHover(Math.max(0, Math.min(xs.length - 1, i))); }; const everyN = Math.max(1, Math.ceil(xs.length / Math.max(2, Math.floor(iw / 56)))); return (
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)}> {series.map((s, si) => ( ))} {ticks.map((t) => ( {fmtCompact(t, 0)} ))} {xs.map((x, i) => (i % everyN === 0 || i === xs.length - 1) && ( {x} ))} {series.map((s, si) => { const color = s.color ?? colors[si % colors.length]!; 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); if (!pts.length) return null; const d = pts .map((p, k) => { if (k === 0) return `M${xAt(p.i).toFixed(1)},${yAt(p.y).toFixed(1)}`; if (s.step) return `H${xAt(p.i).toFixed(1)} V${yAt(p.y).toFixed(1)}`; return `L${xAt(p.i).toFixed(1)},${yAt(p.y).toFixed(1)}`; }) .join(" "); const area = `${d} L${xAt(pts[pts.length - 1]!.i).toFixed(1)},${yAt(0)} L${xAt(pts[0]!.i).toFixed(1)},${yAt(0)} Z`; return ( {s.area && } {hover !== null && pts.find((p) => p.i === hover) && p.i === hover)!.y)} r={3.5} fill={color} stroke="var(--surface)" strokeWidth={2} />} ); })} {hover !== null && } {yLabel &&
{yLabel}
} {hover !== null && (
{xs[hover]}
{series.map((s, si) => { const v = s.data.find((p) => String(p.x) === xs[hover])?.y ?? null; return (
{s.name} {format(v)} {unit && {unit}}
); })}
)} {series.length >= 2 && (
    {series.map((s, si) => (
  • {s.name}
  • ))}
)}
); } export function AreaChart(props: Parameters[0]) { return ({ ...s, area: s.area ?? true }))} />; } /* ---------------------------------------------------------------- Donut (small) */ export 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 }) { const total = segments.reduce((a, s) => a + s.value, 0) || 1; const r = (size - thickness) / 2; const c = 2 * Math.PI * r; const offsets = segments.reduce((acc, s, i) => { acc.push(i === 0 ? 0 : acc[i - 1]! + (segments[i - 1]!.value / total) * c); return acc; }, []); return (
`${s.label} ${Math.round((s.value / total) * 100)}%`).join(", ")}> {segments.map((s, i) => { const len = (s.value / total) * c; return ; })} {label && ( {label} )}
    {segments.map((s, i) => (
  • {s.label} {Math.round((s.value / total) * 100)}%
  • ))}
); }