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%
9.6 KB · 217 lines tsx
Raw Blame History
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