spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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