spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { DensityView, MapLayer, TimeMachine } from "@dci/core";4import { DENSITY_VIEWS, MAP_LAYERS } from "@dci/core";5import { Activity, Building2, Clock, Cloud, Cpu, Gauge, Grid2x2, Network, Pause, Play, Share2, Zap } from "lucide-react";6import { useEffect, useRef, useState, type ComponentType } from "react";7import { cn } from "@/components/ui/cn";8import { apiGet, clientApiBase } from "@/lib/api";9import { fmtPct } from "@/lib/format";10import { DENSITY_LABEL, MAP_LAYER_LABEL } from "@/lib/labels";1112/* ------------------------------------------------------------------------------------------13 Map 2.0 controls: MODES rail (layer=), DENSITY toggle (density=), TIME MACHINE (year=).14 Pure presentational + URL callbacks; the page owns the state.15 ------------------------------------------------------------------------------------------ */1617const LAYER_ICON: Record<MapLayer, ComponentType<{ className?: string; strokeWidth?: number }>> = {18 facilities: Building2,19 capacity: Gauge,20 projects: Activity,21 ai: Cpu,22 cloud: Cloud,23 ixps: Share2,24 connectivity: Network,25 power: Zap,26 pipeline: Grid2x2,27};2829export const LAYER_HINT: Record<MapLayer, string> = {30 facilities: "Every indexed facility, coloured by status",31 capacity: "Facilities with a published MW figure, sized by capacity",32 projects: "Announced, permitted and under-construction projects (square markers)",33 ai: "Facilities and projects with AI / HPC evidence",34 cloud: "Public cloud regions (hollow accent circles) — providers publish the metro, not the building",35 ixps: "Internet exchange points (triangles)",36 connectivity: "Carrier hotels, IXPs and cloud on-ramps",37 power: "Utility-scale loads, grid connections and power events",38 pipeline: "Pipeline by lifecycle stage",39};4041export function ModesRail({ layer, onChange, orientation = "vertical", className }: { layer: MapLayer; onChange: (l: MapLayer) => void; orientation?: "vertical" | "horizontal"; className?: string }) {42 const vertical = orientation === "vertical";43 return (44 <nav aria-label="Map modes" className={cn(vertical ? "flex flex-col" : "flex gap-1 overflow-x-auto no-scrollbar", className)}>45 {vertical && <div className="label-caps px-2 pb-1.5 pt-2">Modes</div>}46 {MAP_LAYERS.map((l) => {47 const Icon = LAYER_ICON[l];48 const active = l === layer;49 return (50 <button51 key={l}52 type="button"53 aria-pressed={active}54 title={LAYER_HINT[l]}55 onClick={() => onChange(l)}56 className={cn(57 "inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-sm text-[12.5px] font-medium transition-colors",58 vertical ? "h-9 w-full px-2 text-left" : "h-10 border px-2.5 sm:h-9",59 active ? (vertical ? "bg-ink text-paper" : "border-ink bg-ink text-paper") : vertical ? "text-ink-2 hover:bg-surface-2 hover:text-ink" : "border-hair-2 bg-surface/95 text-ink-2 backdrop-blur hover:text-ink",60 )}61 >62 <Icon className="size-4 shrink-0" strokeWidth={1.75} />63 <span className={vertical ? "truncate" : undefined}>{MAP_LAYER_LABEL[l]}</span>64 </button>65 );66 })}67 </nav>68 );69}7071export function DensityToggle({ density, onChange, className }: { density: DensityView | null; onChange: (d: DensityView | null) => void; className?: string }) {72 return (73 <div className={className}>74 <div className="flex items-center justify-between px-2 pb-1.5 pt-2">75 <span className="label-caps">Density</span>76 {density && (77 <button type="button" onClick={() => onChange(null)} className="text-[11px] text-ink-3 hover:text-ink">78 Off79 </button>80 )}81 </div>82 <div className="flex flex-col">83 {DENSITY_VIEWS.map((d) => {84 const active = d === density;85 return (86 <button key={d} type="button" aria-pressed={active} onClick={() => onChange(active ? null : d)} className={cn("inline-flex h-8 w-full items-center gap-2 rounded-sm px-2 text-left text-[12px] transition-colors", active ? "bg-surface-3 text-ink" : "text-ink-2 hover:bg-surface-2 hover:text-ink")}>87 <span aria-hidden className={cn("inline-block size-[9px] rounded-[1px]", active ? (d === "known_mw" || d === "pipeline_mw" ? "bg-accent" : "bg-ink") : "border border-hair-2")} />88 <span className="truncate">{DENSITY_LABEL[d]}</span>89 </button>90 );91 })}92 </div>93 </div>94 );95}9697/** Density legend: graduated grid from light to dark (or accent for MW views). */98export function DensityLegend({ density, max }: { density: DensityView; max: number | null }) {99 const mw = density === "known_mw" || density === "pipeline_mw";100 const fmt = (v: number) => (mw ? (v >= 1000 ? `${(v / 1000).toFixed(1)} GW` : `${Math.round(v)} MW`) : String(Math.round(v)));101 return (102 <div className="text-[11px] text-ink-2">103 <div className="mb-1 font-medium">{DENSITY_LABEL[density]} per cell</div>104 <div className="flex h-2 w-full overflow-hidden rounded-[1px]">105 {[0.06, 0.18, 0.3, 0.42, 0.55].map((o) => (106 <span key={o} className="flex-1" style={{ background: mw ? "var(--accent)" : "var(--ink)", opacity: o }} />107 ))}108 </div>109 <div className="figure mt-0.5 flex justify-between text-ink-3">110 <span>0</span>111 <span>{max !== null ? fmt(max) : "max"}</span>112 </div>113 <p className="mt-1 text-ink-4">{mw ? "Known published MW only — cells without a figure stay empty." : "Counts of indexed records per cell."}</p>114 </div>115 );116}117118const THIS_YEAR = new Date().getUTCFullYear();119120/**121 * Time machine (§20): year slider from earliestReliableYear to now, PLAY steps one year per tick, the caption states122 * the share of facilities whose opening date is known — so an early year is read as a lower bound, never as history.123 */124export function TimeMachineControl({ year, onChange, coverage, compact, className }: { year: number | null; onChange: (y: number | null) => void; coverage: number | null; compact?: boolean; className?: string }) {125 const [tm, setTm] = useState<TimeMachine | null>(null);126 const [playing, setPlaying] = useState(false);127 const timer = useRef<ReturnType<typeof setInterval> | null>(null);128 useEffect(() => {129 const ctrl = new AbortController();130 apiGet<TimeMachine>("/time-machine", undefined, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 8000 }).then((r) => {131 if (!ctrl.signal.aborted && r.ok) setTm(r.data);132 });133 return () => ctrl.abort();134 }, []);135 const min = tm?.earliestReliableYear ?? 1995;136 const max = THIS_YEAR;137 const y = year ?? max;138139 useEffect(() => {140 if (!playing) {141 if (timer.current) clearInterval(timer.current);142 timer.current = null;143 return;144 }145 timer.current = setInterval(() => {146 if (y + 1 >= max) {147 setPlaying(false);148 onChange(null);149 } else onChange(y + 1);150 }, 800);151 return () => {152 if (timer.current) clearInterval(timer.current);153 };154 }, [playing, y, min, max, onChange]);155156 const frame = tm?.frames.find((f) => f.year === y) ?? null;157 const cov = coverage ?? tm?.openingDateCoverage ?? null;158159 return (160 <div className={cn("flex flex-col gap-1.5", className)}>161 <div className="flex items-center justify-between gap-2 px-2 pt-2">162 <span className="label-caps inline-flex items-center gap-1.5">163 <Clock className="size-3.5" strokeWidth={1.75} aria-hidden /> Time machine164 </span>165 {year !== null && (166 <button type="button" onClick={() => { setPlaying(false); onChange(null); }} className="text-[11px] text-ink-3 hover:text-ink">167 Now168 </button>169 )}170 </div>171 <div className="flex items-center gap-2 px-2">172 <button173 type="button"174 onClick={() => {175 if (!playing && year === null) onChange(min);176 setPlaying((p) => !p);177 }}178 aria-label={playing ? "Pause" : "Play through the years"}179 aria-pressed={playing}180 className="inline-flex size-9 shrink-0 items-center justify-center rounded-sm border border-hair-2 bg-surface text-ink-2 hover:text-ink sm:size-8"181 >182 {playing ? <Pause className="size-4" strokeWidth={1.75} /> : <Play className="size-4" strokeWidth={1.75} />}183 </button>184 <input185 type="range"186 min={min}187 max={max}188 step={1}189 value={y}190 aria-label="Year"191 aria-valuetext={`${y}`}192 onChange={(e) => {193 setPlaying(false);194 const v = Number(e.target.value);195 onChange(v >= max ? null : v);196 }}197 className="h-9 min-w-0 flex-1 accent-[var(--accent)] sm:h-6"198 />199 <span className="figure w-10 shrink-0 text-right text-[13px] font-medium text-ink">{y}</span>200 </div>201 {!compact && (202 <div className="px-2 pb-1 text-[11px] leading-snug text-ink-3">203 {year === null ? (204 <span>Drag or play to see facilities known open by a given year.</span>205 ) : (206 <span>207 Facilities known open by <span className="figure text-ink-2">{y}</span>208 {frame && frame.facilities > 0 ? <> · {frame.facilities.toLocaleString("en-US")} facilities{frame.knownMw ? `, ${frame.knownMw >= 1000 ? `${(frame.knownMw / 1000).toFixed(1)} GW` : `${Math.round(frame.knownMw)} MW`} known` : ""}</> : null}209 </span>210 )}211 {cov !== null && <span className="block text-ink-4">Opening date known for {fmtPct(cov, cov < 0.1 ? 1 : 0)} of facilities — earlier years are lower bounds.</span>}212 </div>213 )}214 </div>215 );216}217