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