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%
25.5 KB · 467 lines tsx
Raw Blame History
1"use client";23import type { CloudRegionSummary, DensityView, FacilityFilters, FacilityStatus, MapCluster, MapLayer, MapPoint, MapResponse } from "@dci/core";4import maplibregl, { type GeoJSONSource, type LngLatBoundsLike, type Map as MLMap, type MapMouseEvent } from "maplibre-gl";5import "maplibre-gl/dist/maplibre-gl.css";6import type { FeatureCollection, Point } from "geojson";7import { useTheme } from "next-themes";8import { useEffect, useRef, useState } from "react";9import { cn } from "@/components/ui/cn";10import { apiGet, clientApiBase } from "@/lib/api";11import { STATUS_TONE, type StatusTone } from "@/lib/labels";1213/* ------------------------------------------------------------------------------------------14   MapLibre canvas (Map 2.0). Data from GET /map?zoom&bbox&layer&density&year&filters (debounced on15   moveend). Visual language (§18):16   - facilities: circle sized by known MW (sqrt scale), filled for exact / parcel / street, hollow ring17     for city / metro / approximate; rumored drawn lighter; AI facilities get an accent diamond18   - projects: square "construction" marker coloured by stage, hollow ring underneath when imprecise19   - IXPs: ink triangle; cloud regions: hollow accent circle (k overlay symbols)20   - density: graduated grid cells (MapResponse.density) with value labels21   - clusters: sized circles labelled with counts, coloured by dominant status22   Colours are read from CSS variables at style load so the map follows the theme.23   ------------------------------------------------------------------------------------------ */2425export const STYLE_LIGHT = "https://tiles.openfreemap.org/styles/positron";26export const STYLE_DARK = "https://tiles.openfreemap.org/styles/dark";27const STYLE_DARK_FALLBACK = "https://tiles.openfreemap.org/styles/fiord";2829export interface MapView {30  lng: number;31  lat: number;32  zoom: number;33}3435export interface FlyTarget {36  bbox?: [number, number, number, number];37  center?: [number, number];38  zoom?: number;39  key: number; // change to retrigger40}4142export type MapExtraQuery = Record<string, string | number | boolean | undefined>;4344export interface MapCanvasProps {45  className?: string;46  /** home preview: non-interactive, fixed zoom, no controls */47  preview?: boolean;48  filters?: FacilityFilters;49  showCloudRegions?: boolean;50  initialView?: MapView;51  onViewChange?: (v: MapView) => void;52  onData?: (r: MapResponse | null, error?: string) => void;53  onSelect?: (p: MapPoint) => void;54  onSelectCloudRegion?: (r: CloudRegionSummary) => void;55  flyTo?: FlyTarget | null;56  /** extra bottom padding (px) so the quick-profile sheet doesn't cover the selected point */57  paddingBottom?: number;58  /** Map 2.0: mode / density / time machine + extra filters (location_precision, ai_evidence, project_status, expected_from/to) */59  layer?: MapLayer;60  density?: DensityView;61  year?: number;62  extraQuery?: MapExtraQuery;63  /** preview: allow interaction but no controls (home hero) */64  interactivePreview?: boolean;65}6667const TONES: StatusTone[] = ["operational", "construction", "planned", "permitting", "delayed", "closed", "rumored", "unknown"];6869interface Palette extends Record<StatusTone, string> {70  ink: string;71  surface: string;72  accent: string;73  paper: string;74}7576function readColors(): Palette {77  const cs = getComputedStyle(document.documentElement);78  const get = (v: string, fb: string) => cs.getPropertyValue(v).trim() || fb;79  const out = {} as Palette;80  for (const t of TONES) out[t] = get(`--st-${t}`, "#888");81  out.ink = get("--ink", "#0b0f14");82  out.surface = get("--surface", "#fff");83  out.accent = get("--accent", "#c8541a");84  out.paper = get("--paper", "#f5f4f1");85  return out;86}8788function dominantTone(c: MapCluster): StatusTone {89  const acc: Partial<Record<StatusTone, number>> = {};90  for (const [s, n] of Object.entries(c.statuses) as Array<[FacilityStatus, number]>) {91    const t = STATUS_TONE[s] ?? "unknown";92    acc[t] = (acc[t] ?? 0) + (n ?? 0);93  }94  let best: StatusTone = "unknown";95  let max = -1;96  for (const t of TONES) if ((acc[t] ?? 0) > max) [best, max] = [t, acc[t] ?? 0];97  return best;98}99100function filterQuery(f: FacilityFilters | undefined): Record<string, string | number | boolean | undefined> {101  if (!f) return {};102  const q: Record<string, string | number | boolean | undefined> = {};103  for (const [k, v] of Object.entries(f)) if (v !== undefined && v !== null && v !== "" && !["page", "per_page", "sort", "order"].includes(k)) q[k] = v as string | number | boolean;104  return q;105}106107const EMPTY: FeatureCollection = { type: "FeatureCollection", features: [] };108109/** Radius (px at zoom 10) from MW — sqrt scale, 4 px floor (unknown MW), 16 px cap. */110function radiusForMw(mw: number | null | undefined): number {111  if (mw === null || mw === undefined || !Number.isFinite(mw) || mw <= 0) return 4;112  return Math.min(16, 4 + Math.sqrt(mw) * 0.55);113}114115const imprecise = (p: MapPoint["p"]) => p === "city" || p === "metro" || p === "approximate" || p === "unknown";116117/** Draw a marker shape into ImageData for map.addImage (2× for retina). */118function shapeImage(kind: "square" | "diamond" | "triangle" | "ring", color: string, stroke: string, size = 28): ImageData {119  const s = size;120  const canvas = document.createElement("canvas");121  canvas.width = s;122  canvas.height = s;123  const ctx = canvas.getContext("2d")!;124  ctx.clearRect(0, 0, s, s);125  const pad = 4;126  ctx.lineWidth = 3;127  ctx.strokeStyle = stroke;128  ctx.fillStyle = color;129  ctx.beginPath();130  if (kind === "square") {131    ctx.rect(pad, pad, s - pad * 2, s - pad * 2);132  } else if (kind === "diamond") {133    ctx.moveTo(s / 2, pad);134    ctx.lineTo(s - pad, s / 2);135    ctx.lineTo(s / 2, s - pad);136    ctx.lineTo(pad, s / 2);137    ctx.closePath();138  } else if (kind === "triangle") {139    ctx.moveTo(s / 2, pad);140    ctx.lineTo(s - pad, s - pad);141    ctx.lineTo(pad, s - pad);142    ctx.closePath();143  } else {144    ctx.arc(s / 2, s / 2, s / 2 - pad, 0, Math.PI * 2);145  }146  if (kind === "ring") {147    ctx.lineWidth = 4;148    ctx.strokeStyle = color;149    ctx.stroke();150  } else {151    ctx.fill();152    ctx.stroke();153  }154  return ctx.getImageData(0, 0, s, s);155}156157export function MapCanvas({ className, preview, interactivePreview, filters, showCloudRegions, initialView, onViewChange, onData, onSelect, onSelectCloudRegion, flyTo, paddingBottom = 0, layer, density, year, extraQuery }: MapCanvasProps) {158  const container = useRef<HTMLDivElement>(null);159  const mapRef = useRef<MLMap | null>(null);160  const [ready, setReady] = useState(false);161  const { resolvedTheme } = useTheme();162  const dark = resolvedTheme === "dark";163  const abortRef = useRef<AbortController | null>(null);164  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);165  const queryRef = useRef({ filters, layer, density, year, extraQuery });166  const cbRef = useRef({ onViewChange, onData, onSelect, onSelectCloudRegion });167  useEffect(() => {168    queryRef.current = { filters, layer, density, year, extraQuery };169    cbRef.current = { onViewChange, onData, onSelect, onSelectCloudRegion };170  });171  const cloudCache = useRef<CloudRegionSummary[] | null>(null);172  const querySig = JSON.stringify({ filters, layer, density, year, extraQuery });173174  /* ---------- init */175  useEffect(() => {176    if (!container.current || mapRef.current) return;177    const view = initialView ?? (preview ? { lng: 10, lat: 25, zoom: 1.2 } : { lng: 10, lat: 30, zoom: 1.6 });178    const interactive = !preview || !!interactivePreview;179    const map = new maplibregl.Map({180      container: container.current,181      style: dark ? STYLE_DARK : STYLE_LIGHT,182      center: [view.lng, view.lat],183      zoom: view.zoom,184      minZoom: 1,185      maxZoom: 17,186      attributionControl: false,187      interactive,188      cooperativeGestures: false,189      dragRotate: false,190      pitchWithRotate: false,191      touchPitch: false,192      renderWorldCopies: true,193    });194    map.touchZoomRotate.disableRotation();195    map.addControl(new maplibregl.AttributionControl({ compact: true, customAttribution: "Facilities © DataCenterIndex" }), "bottom-right");196    if (!preview) {197      map.addControl(new maplibregl.NavigationControl({ showCompass: false, visualizePitch: false }), "bottom-right");198      map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: false }, fitBoundsOptions: { maxZoom: 9 } }), "bottom-right");199    }200    mapRef.current = map;201202    map.on("error", (e) => {203      const msg = String((e as { error?: { message?: string } }).error?.message ?? "");204      if (/style|styles\/dark|Failed to fetch/i.test(msg) && dark) map.setStyle(STYLE_DARK_FALLBACK);205    });206207    const setup = () => {208      const c = readColors();209      for (const src of ["clusters", "points", "overlay", "cloud", "density"]) if (!map.getSource(src)) map.addSource(src, { type: "geojson", data: EMPTY });210      const toneColor: unknown[] = ["match", ["get", "tone"]];211      for (const t of TONES) toneColor.push(t, c[t]);212      toneColor.push(c.unknown);213      // marker images (per tone for projects; single for AI / IXP / cloud)214      for (const t of TONES) {215        const id = `project-${t}`;216        if (map.hasImage(id)) map.removeImage(id);217        map.addImage(id, shapeImage("square", c[t], c.surface), { pixelRatio: 2 });218      }219      for (const [id, img] of [220        ["ai-diamond", shapeImage("diamond", c.accent, c.surface)],221        ["ixp-triangle", shapeImage("triangle", c.ink, c.surface)],222        ["cloud-ring", shapeImage("ring", c.accent, c.surface)],223        ["power-triangle", shapeImage("triangle", c.construction, c.surface)],224      ] as Array<[string, ImageData]>) {225        if (map.hasImage(id)) map.removeImage(id);226        map.addImage(id, img, { pixelRatio: 2 });227      }228229      // zoom-scaled radius from the per-feature `r` (computed at zoom ~10 basis)230      const rScaled: unknown = ["interpolate", ["linear"], ["zoom"], 3, ["*", ["get", "r"], 0.75], 8, ["*", ["get", "r"], 1], 14, ["*", ["get", "r"], 1.5]];231232      /* density grid */233      if (!map.getLayer("density-fill"))234        map.addLayer({ id: "density-fill", type: "fill", source: "density", paint: { "fill-color": ["case", ["==", ["get", "accent"], 1], c.accent, c.ink], "fill-opacity": ["interpolate", ["linear"], ["get", "t"], 0, 0.04, 1, 0.55] } });235      if (!map.getLayer("density-line")) map.addLayer({ id: "density-line", type: "line", source: "density", paint: { "line-color": c.surface, "line-width": 0.6, "line-opacity": 0.6 } });236      if (!map.getLayer("density-label"))237        map.addLayer({ id: "density-label", type: "symbol", source: "density", filter: [">", ["get", "t"], 0.08], layout: { "text-field": ["get", "label"], "text-size": 11, "text-font": ["Noto Sans Bold"], "text-allow-overlap": false }, paint: { "text-color": c.ink, "text-halo-color": c.paper, "text-halo-width": 1.2 } });238239      /* clusters */240      if (!map.getLayer("cluster-halo"))241        map.addLayer({ id: "cluster-halo", type: "circle", source: "clusters", paint: { "circle-color": toneColor as never, "circle-opacity": 0.16, "circle-radius": ["+", ["get", "r"], 8] } });242      if (!map.getLayer("cluster-circle"))243        map.addLayer({ id: "cluster-circle", type: "circle", source: "clusters", paint: { "circle-color": toneColor as never, "circle-radius": ["get", "r"], "circle-stroke-width": 1.5, "circle-stroke-color": c.surface } });244      if (!map.getLayer("cluster-count"))245        map.addLayer({ id: "cluster-count", type: "symbol", source: "clusters", layout: { "text-field": ["get", "label"], "text-size": ["step", ["get", "count"], 11, 100, 12, 1000, 13], "text-font": ["Noto Sans Bold"], "text-allow-overlap": true }, paint: { "text-color": "#ffffff" } });246      if (!map.getLayer("cluster-name"))247        map.addLayer({ id: "cluster-name", type: "symbol", source: "clusters", filter: ["has", "name"], layout: { "text-field": ["get", "name"], "text-size": 11, "text-font": ["Noto Sans Regular"], "text-anchor": "top", "text-offset": [0, 1.1], "text-optional": true, "text-variable-anchor": ["top", "bottom"] }, paint: { "text-color": c.ink, "text-halo-color": c.surface, "text-halo-width": 1.2 } });248249      /* facility points */250      if (!map.getLayer("point-imprecise"))251        map.addLayer({ id: "point-imprecise", type: "circle", source: "points", filter: ["==", ["get", "imprecise"], 1], paint: { "circle-color": toneColor as never, "circle-opacity": 0.1, "circle-radius": ["+", rScaled as never, 3], "circle-stroke-width": 1.75, "circle-stroke-color": toneColor as never, "circle-stroke-opacity": ["case", ["==", ["get", "tone"], "rumored"], 0.6, 0.95] } });252      if (!map.getLayer("point-precise"))253        map.addLayer({ id: "point-precise", type: "circle", source: "points", filter: ["==", ["get", "imprecise"], 0], paint: { "circle-color": toneColor as never, "circle-radius": rScaled as never, "circle-stroke-width": 1.2, "circle-stroke-color": c.surface, "circle-opacity": ["case", ["==", ["get", "tone"], "rumored"], 0.55, 0.95] } });254      if (!map.getLayer("point-ai"))255        map.addLayer({ id: "point-ai", type: "symbol", source: "points", filter: ["==", ["get", "ai"], 1], layout: { "icon-image": "ai-diamond", "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 10, 0.5, 14, 0.7], "icon-allow-overlap": true, "icon-anchor": "bottom", "icon-offset": [0, -4] } });256      if (!map.getLayer("point-label"))257        map.addLayer({ id: "point-label", type: "symbol", source: "points", minzoom: 9, layout: { "text-field": ["get", "n"], "text-size": 11, "text-font": ["Noto Sans Regular"], "text-anchor": "left", "text-offset": [0.9, 0], "text-optional": true }, paint: { "text-color": c.ink, "text-halo-color": c.surface, "text-halo-width": 1.2 } });258259      /* overlay symbols (projects, IXPs, cloud regions, power) */260      if (!map.getLayer("overlay-ring"))261        map.addLayer({ id: "overlay-ring", type: "circle", source: "overlay", filter: ["all", ["==", ["get", "k"], "project"], ["==", ["get", "imprecise"], 1]], paint: { "circle-color": toneColor as never, "circle-opacity": 0.08, "circle-radius": ["+", rScaled as never, 8], "circle-stroke-width": 1.25, "circle-stroke-color": toneColor as never, "circle-stroke-opacity": 0.7 } });262      if (!map.getLayer("overlay-project"))263        map.addLayer({ id: "overlay-project", type: "symbol", source: "overlay", filter: ["==", ["get", "k"], "project"], layout: { "icon-image": ["concat", "project-", ["get", "tone"]], "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.4, 8, 0.55, 14, 0.8], "icon-allow-overlap": true } });264      if (!map.getLayer("overlay-ixp"))265        map.addLayer({ id: "overlay-ixp", type: "symbol", source: "overlay", filter: ["==", ["get", "k"], "ixp"], layout: { "icon-image": "ixp-triangle", "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.38, 8, 0.5, 14, 0.7], "icon-allow-overlap": true } });266      if (!map.getLayer("overlay-cloud"))267        map.addLayer({ id: "overlay-cloud", type: "symbol", source: "overlay", filter: ["==", ["get", "k"], "cloud_region"], layout: { "icon-image": "cloud-ring", "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.42, 8, 0.55, 14, 0.75], "icon-allow-overlap": true } });268      if (!map.getLayer("overlay-power"))269        map.addLayer({ id: "overlay-power", type: "symbol", source: "overlay", filter: ["in", ["get", "k"], ["literal", ["landing_station", "substation", "power_plant"]]], layout: { "icon-image": "power-triangle", "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.38, 8, 0.5, 14, 0.7], "icon-allow-overlap": true } });270      if (!map.getLayer("overlay-label"))271        map.addLayer({ id: "overlay-label", type: "symbol", source: "overlay", minzoom: 7, layout: { "text-field": ["get", "n"], "text-size": 10.5, "text-font": ["Noto Sans Regular"], "text-anchor": "left", "text-offset": [1, 0], "text-optional": true }, paint: { "text-color": c.ink, "text-halo-color": c.surface, "text-halo-width": 1.2 } });272273      /* legacy cloud layer (cr=1 toggle) */274      if (!map.getLayer("cloud-square"))275        map.addLayer({ id: "cloud-square", type: "symbol", source: "cloud", layout: { "icon-image": "cloud-ring", "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.42, 8, 0.55], "icon-allow-overlap": true } });276      setReady(true);277      load();278    };279    map.on("style.load", setup);280281    const emitView = () => {282      const cnt = map.getCenter();283      cbRef.current.onViewChange?.({ lng: +cnt.lng.toFixed(4), lat: +cnt.lat.toFixed(4), zoom: +map.getZoom().toFixed(2) });284    };285286    const load = () => {287      if (timer.current) clearTimeout(timer.current);288      timer.current = setTimeout(async () => {289        abortRef.current?.abort();290        const ctrl = new AbortController();291        abortRef.current = ctrl;292        const b = map.getBounds();293        const zoom = map.getZoom();294        const w = Math.max(-180, b.getWest());295        const e = Math.min(180, b.getEast());296        const bbox = zoom < 2.5 ? "-180,-85,180,85" : `${w.toFixed(4)},${b.getSouth().toFixed(4)},${e.toFixed(4)},${b.getNorth().toFixed(4)}`;297        const q = queryRef.current;298        const r = await apiGet<MapResponse>("/map", { zoom: zoom.toFixed(2), bbox, ...filterQuery(q.filters), layer: q.layer && q.layer !== "facilities" ? q.layer : undefined, density: q.density, year: q.year, ...(q.extraQuery ?? {}) }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 12000 });299        if (ctrl.signal.aborted) return;300        if (!r.ok) {301          cbRef.current.onData?.(null, r.error);302          return;303        }304        applyData(r.data);305        cbRef.current.onData?.(r.data);306      }, preview ? 0 : 220);307    };308309    const pointProps = (p: MapPoint) => ({ ...p, tone: STATUS_TONE[p.s] ?? "unknown", imprecise: imprecise(p.p) ? 1 : 0, r: radiusForMw(p.mw), k: p.k ?? "facility", ai: p.ai ? 1 : 0 });310311    const applyData = (d: MapResponse) => {312      const clusters = map.getSource("clusters") as GeoJSONSource | undefined;313      const points = map.getSource("points") as GeoJSONSource | undefined;314      const overlay = map.getSource("overlay") as GeoJSONSource | undefined;315      const dens = map.getSource("density") as GeoJSONSource | undefined;316      if (!clusters || !points || !overlay || !dens) return;317318      // density grid319      if (d.mode === "density" && d.density?.cells.length) {320        const cells = d.density.cells;321        const lats = Array.from(new Set(cells.map((c) => c.lat))).sort((a, b) => a - b);322        const lngs = Array.from(new Set(cells.map((c) => c.lng))).sort((a, b) => a - b);323        const minDiff = (arr: number[], fb: number) => {324          let m = Infinity;325          for (let i = 1; i < arr.length; i++) m = Math.min(m, arr[i]! - arr[i - 1]!);326          return Number.isFinite(m) ? m : fb;327        };328        const dh = minDiff(lats, 45);329        const dw = minDiff(lngs, 45);330        const max = Math.max(1e-9, d.density.max || Math.max(...cells.map((c) => c.w)));331        const isMw = d.density.view === "known_mw" || d.density.view === "pipeline_mw";332        dens.setData({333          type: "FeatureCollection",334          features: cells335            .filter((c) => c.w > 0 || c.n > 0)336            .map((c) => {337              const t = Math.sqrt(Math.max(0, c.w) / max);338              const label = isMw ? (c.w >= 1000 ? `${(c.w / 1000).toFixed(1)} GW` : `${Math.round(c.w)} MW`) : String(Math.round(c.w));339              const s = Math.max(-85, c.lat - dh / 2);340              const n = Math.min(85, c.lat + dh / 2);341              return { type: "Feature" as const, geometry: { type: "Polygon" as const, coordinates: [[[c.lng - dw / 2, s], [c.lng + dw / 2, s], [c.lng + dw / 2, n], [c.lng - dw / 2, n], [c.lng - dw / 2, s]]] }, properties: { t, w: c.w, n: c.n, label, accent: isMw ? 1 : 0 } };342            }),343        });344      } else dens.setData(EMPTY);345346      if (d.mode === "clusters") {347        const max = Math.max(1, ...(d.clusters ?? []).map((c) => c.count));348        clusters.setData({349          type: "FeatureCollection",350          features: (d.clusters ?? []).map((c) => ({351            type: "Feature",352            geometry: { type: "Point", coordinates: [c.lng, c.lat] },353            properties: { key: c.key, count: c.count, label: c.count >= 1000 ? `${(c.count / 1000).toFixed(1)}k` : String(c.count), r: 12 + Math.sqrt(c.count / max) * 18, tone: dominantTone(c), name: c.label ?? undefined, mw: c.mw },354          })),355        });356        points.setData(EMPTY);357      } else {358        clusters.setData(EMPTY);359        const facilityPts = (d.points ?? []).filter((p) => !p.k || p.k === "facility");360        const others = (d.points ?? []).filter((p) => p.k && p.k !== "facility");361        points.setData({ type: "FeatureCollection", features: facilityPts.map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) });362        const ov = [...(d.overlay ?? []), ...others];363        overlay.setData({ type: "FeatureCollection", features: ov.map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) });364        return;365      }366      overlay.setData({ type: "FeatureCollection", features: (d.overlay ?? []).map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) });367    };368369    if (interactive) {370      map.on("moveend", () => {371        emitView();372        load();373      });374      map.on("click", "cluster-circle", (e: MapMouseEvent) => {375        const f = map.queryRenderedFeatures(e.point, { layers: ["cluster-circle"] })[0];376        if (!f) return;377        const [lng, lat] = (f.geometry as Point).coordinates;378        map.easeTo({ center: [lng!, lat!], zoom: Math.min(17, map.getZoom() + (map.getZoom() < 4 ? 2.5 : 2)), duration: 500 });379      });380      map.on("click", "density-fill", (e: MapMouseEvent) => {381        map.easeTo({ center: e.lngLat, zoom: Math.min(17, map.getZoom() + 2), duration: 500 });382      });383      const pointLayers = ["point-precise", "point-imprecise", "point-ai", "overlay-project", "overlay-ixp", "overlay-cloud", "overlay-power", "overlay-ring"];384      const onPoint = (e: MapMouseEvent) => {385        const f = map.queryRenderedFeatures(e.point, { layers: pointLayers.filter((l) => !!map.getLayer(l)) })[0];386        if (!f) return;387        const p = f.properties as unknown as MapPoint & { tone: string; imprecise: number; k: string; ai: number };388        cbRef.current.onSelect?.({ id: p.id, slug: p.slug, n: p.n, o: p.o ?? null, lat: p.lat, lng: p.lng, s: p.s, t: p.t, mw: p.mw ?? null, p: p.p, ai: p.ai ? 1 : undefined, hs: p.hs, c: p.c ?? null, k: (p.k as MapPoint["k"]) ?? "facility", y: p.y ?? null, rs: p.rs });389      };390      for (const l of pointLayers) map.on("click", l, onPoint);391      map.on("click", "cloud-square", (e: MapMouseEvent) => {392        const f = map.queryRenderedFeatures(e.point, { layers: ["cloud-square"] })[0];393        if (!f) return;394        const id = (f.properties as { id: string }).id;395        const cr = cloudCache.current?.find((x) => x.id === id);396        if (cr) cbRef.current.onSelectCloudRegion?.(cr);397      });398      for (const l of ["cluster-circle", ...pointLayers, "cloud-square", "density-fill"]) {399        map.on("mouseenter", l, () => (map.getCanvas().style.cursor = "pointer"));400        map.on("mouseleave", l, () => (map.getCanvas().style.cursor = ""));401      }402    }403404    (map as MLMap & { __load?: () => void }).__load = load;405    return () => {406      abortRef.current?.abort();407      if (timer.current) clearTimeout(timer.current);408      map.remove();409      mapRef.current = null;410    };411    // eslint-disable-next-line react-hooks/exhaustive-deps412  }, []);413414  /* ---------- theme change → swap style (layers re-added on style.load) */415  useEffect(() => {416    const map = mapRef.current;417    if (!map || !ready) return;418    const want = dark ? STYLE_DARK : STYLE_LIGHT;419    const cur = (map.getStyle() as { name?: string } | undefined)?.name ?? "";420    if ((dark && /positron/i.test(cur)) || (!dark && !/positron/i.test(cur))) map.setStyle(want);421  }, [dark, ready]);422423  /* ---------- filters / layer / density / year change → reload */424  useEffect(() => {425    const map = mapRef.current as (MLMap & { __load?: () => void }) | null;426    if (!map || !ready) return;427    map.__load?.();428  }, [querySig, ready]);429430  /* ---------- cloud regions layer (legacy toggle) */431  useEffect(() => {432    const map = mapRef.current;433    if (!map || !ready) return;434    const src = map.getSource("cloud") as GeoJSONSource | undefined;435    if (!src) return;436    if (!showCloudRegions) {437      src.setData(EMPTY);438      return;439    }440    const ctrl = new AbortController();441    (async () => {442      if (!cloudCache.current) {443        const r = await apiGet<CloudRegionSummary[] | { items: CloudRegionSummary[] }>("/cloud-regions", undefined, { base: clientApiBase(), signal: ctrl.signal });444        if (!r.ok || ctrl.signal.aborted) return;445        cloudCache.current = Array.isArray(r.data) ? r.data : r.data.items;446      }447      src.setData({448        type: "FeatureCollection",449        features: cloudCache.current.filter((c) => c.lat !== null && c.lng !== null).map((c) => ({ type: "Feature", geometry: { type: "Point", coordinates: [c.lng!, c.lat!] }, properties: { id: c.id, name: c.name, code: c.code, provider: c.provider.name } })),450      });451    })();452    return () => ctrl.abort();453  }, [showCloudRegions, ready]);454455  /* ---------- fly-to */456  useEffect(() => {457    const map = mapRef.current;458    if (!map || !flyTo) return;459    const padding = { top: 40, left: 40, right: 40, bottom: 40 + paddingBottom };460    if (flyTo.bbox) map.fitBounds(flyTo.bbox as LngLatBoundsLike, { padding, duration: 700, maxZoom: 12 });461    else if (flyTo.center) map.flyTo({ center: flyTo.center, zoom: flyTo.zoom ?? 11, duration: 700, padding });462    // eslint-disable-next-line react-hooks/exhaustive-deps463  }, [flyTo?.key]);464465  return <div ref={container} className={cn("h-full w-full bg-surface-2", className)} role="region" aria-label="Map of data centers" />;466}467