"use client"; import type { CloudRegionSummary, DensityView, FacilityFilters, FacilityStatus, MapCluster, MapLayer, MapPoint, MapResponse } from "@dci/core"; import maplibregl, { type GeoJSONSource, type LngLatBoundsLike, type Map as MLMap, type MapMouseEvent } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import type { FeatureCollection, Point } from "geojson"; import { useTheme } from "next-themes"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/components/ui/cn"; import { apiGet, clientApiBase } from "@/lib/api"; import { STATUS_TONE, type StatusTone } from "@/lib/labels"; /* ------------------------------------------------------------------------------------------ MapLibre canvas (Map 2.0). Data from GET /map?zoom&bbox&layer&density&year&filters (debounced on moveend). Visual language (§18): - facilities: circle sized by known MW (sqrt scale), filled for exact / parcel / street, hollow ring for city / metro / approximate; rumored drawn lighter; AI facilities get an accent diamond - projects: square "construction" marker coloured by stage, hollow ring underneath when imprecise - IXPs: ink triangle; cloud regions: hollow accent circle (k overlay symbols) - density: graduated grid cells (MapResponse.density) with value labels - clusters: sized circles labelled with counts, coloured by dominant status Colours are read from CSS variables at style load so the map follows the theme. ------------------------------------------------------------------------------------------ */ export const STYLE_LIGHT = "https://tiles.openfreemap.org/styles/positron"; export const STYLE_DARK = "https://tiles.openfreemap.org/styles/dark"; const STYLE_DARK_FALLBACK = "https://tiles.openfreemap.org/styles/fiord"; export interface MapView { lng: number; lat: number; zoom: number; } export interface FlyTarget { bbox?: [number, number, number, number]; center?: [number, number]; zoom?: number; key: number; // change to retrigger } export type MapExtraQuery = Record; export interface MapCanvasProps { className?: string; /** home preview: non-interactive, fixed zoom, no controls */ preview?: boolean; filters?: FacilityFilters; showCloudRegions?: boolean; initialView?: MapView; onViewChange?: (v: MapView) => void; onData?: (r: MapResponse | null, error?: string) => void; onSelect?: (p: MapPoint) => void; onSelectCloudRegion?: (r: CloudRegionSummary) => void; flyTo?: FlyTarget | null; /** extra bottom padding (px) so the quick-profile sheet doesn't cover the selected point */ paddingBottom?: number; /** Map 2.0: mode / density / time machine + extra filters (location_precision, ai_evidence, project_status, expected_from/to) */ layer?: MapLayer; density?: DensityView; year?: number; extraQuery?: MapExtraQuery; /** preview: allow interaction but no controls (home hero) */ interactivePreview?: boolean; } const TONES: StatusTone[] = ["operational", "construction", "planned", "permitting", "delayed", "closed", "rumored", "unknown"]; interface Palette extends Record { ink: string; surface: string; accent: string; paper: string; } function readColors(): Palette { const cs = getComputedStyle(document.documentElement); const get = (v: string, fb: string) => cs.getPropertyValue(v).trim() || fb; const out = {} as Palette; for (const t of TONES) out[t] = get(`--st-${t}`, "#888"); out.ink = get("--ink", "#0b0f14"); out.surface = get("--surface", "#fff"); out.accent = get("--accent", "#c8541a"); out.paper = get("--paper", "#f5f4f1"); return out; } function dominantTone(c: MapCluster): StatusTone { const acc: Partial> = {}; for (const [s, n] of Object.entries(c.statuses) as Array<[FacilityStatus, number]>) { const t = STATUS_TONE[s] ?? "unknown"; acc[t] = (acc[t] ?? 0) + (n ?? 0); } let best: StatusTone = "unknown"; let max = -1; for (const t of TONES) if ((acc[t] ?? 0) > max) [best, max] = [t, acc[t] ?? 0]; return best; } function filterQuery(f: FacilityFilters | undefined): Record { if (!f) return {}; const q: Record = {}; 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; return q; } const EMPTY: FeatureCollection = { type: "FeatureCollection", features: [] }; /** Radius (px at zoom 10) from MW — sqrt scale, 4 px floor (unknown MW), 16 px cap. */ function radiusForMw(mw: number | null | undefined): number { if (mw === null || mw === undefined || !Number.isFinite(mw) || mw <= 0) return 4; return Math.min(16, 4 + Math.sqrt(mw) * 0.55); } const imprecise = (p: MapPoint["p"]) => p === "city" || p === "metro" || p === "approximate" || p === "unknown"; /** Draw a marker shape into ImageData for map.addImage (2× for retina). */ function shapeImage(kind: "square" | "diamond" | "triangle" | "ring", color: string, stroke: string, size = 28): ImageData { const s = size; const canvas = document.createElement("canvas"); canvas.width = s; canvas.height = s; const ctx = canvas.getContext("2d")!; ctx.clearRect(0, 0, s, s); const pad = 4; ctx.lineWidth = 3; ctx.strokeStyle = stroke; ctx.fillStyle = color; ctx.beginPath(); if (kind === "square") { ctx.rect(pad, pad, s - pad * 2, s - pad * 2); } else if (kind === "diamond") { ctx.moveTo(s / 2, pad); ctx.lineTo(s - pad, s / 2); ctx.lineTo(s / 2, s - pad); ctx.lineTo(pad, s / 2); ctx.closePath(); } else if (kind === "triangle") { ctx.moveTo(s / 2, pad); ctx.lineTo(s - pad, s - pad); ctx.lineTo(pad, s - pad); ctx.closePath(); } else { ctx.arc(s / 2, s / 2, s / 2 - pad, 0, Math.PI * 2); } if (kind === "ring") { ctx.lineWidth = 4; ctx.strokeStyle = color; ctx.stroke(); } else { ctx.fill(); ctx.stroke(); } return ctx.getImageData(0, 0, s, s); } export function MapCanvas({ className, preview, interactivePreview, filters, showCloudRegions, initialView, onViewChange, onData, onSelect, onSelectCloudRegion, flyTo, paddingBottom = 0, layer, density, year, extraQuery }: MapCanvasProps) { const container = useRef(null); const mapRef = useRef(null); const [ready, setReady] = useState(false); const { resolvedTheme } = useTheme(); const dark = resolvedTheme === "dark"; const abortRef = useRef(null); const timer = useRef | null>(null); const queryRef = useRef({ filters, layer, density, year, extraQuery }); const cbRef = useRef({ onViewChange, onData, onSelect, onSelectCloudRegion }); useEffect(() => { queryRef.current = { filters, layer, density, year, extraQuery }; cbRef.current = { onViewChange, onData, onSelect, onSelectCloudRegion }; }); const cloudCache = useRef(null); const querySig = JSON.stringify({ filters, layer, density, year, extraQuery }); /* ---------- init */ useEffect(() => { if (!container.current || mapRef.current) return; const view = initialView ?? (preview ? { lng: 10, lat: 25, zoom: 1.2 } : { lng: 10, lat: 30, zoom: 1.6 }); const interactive = !preview || !!interactivePreview; const map = new maplibregl.Map({ container: container.current, style: dark ? STYLE_DARK : STYLE_LIGHT, center: [view.lng, view.lat], zoom: view.zoom, minZoom: 1, maxZoom: 17, attributionControl: false, interactive, cooperativeGestures: false, dragRotate: false, pitchWithRotate: false, touchPitch: false, renderWorldCopies: true, }); map.touchZoomRotate.disableRotation(); map.addControl(new maplibregl.AttributionControl({ compact: true, customAttribution: "Facilities © DataCenterIndex" }), "bottom-right"); if (!preview) { map.addControl(new maplibregl.NavigationControl({ showCompass: false, visualizePitch: false }), "bottom-right"); map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: false }, fitBoundsOptions: { maxZoom: 9 } }), "bottom-right"); } mapRef.current = map; map.on("error", (e) => { const msg = String((e as { error?: { message?: string } }).error?.message ?? ""); if (/style|styles\/dark|Failed to fetch/i.test(msg) && dark) map.setStyle(STYLE_DARK_FALLBACK); }); const setup = () => { const c = readColors(); for (const src of ["clusters", "points", "overlay", "cloud", "density"]) if (!map.getSource(src)) map.addSource(src, { type: "geojson", data: EMPTY }); const toneColor: unknown[] = ["match", ["get", "tone"]]; for (const t of TONES) toneColor.push(t, c[t]); toneColor.push(c.unknown); // marker images (per tone for projects; single for AI / IXP / cloud) for (const t of TONES) { const id = `project-${t}`; if (map.hasImage(id)) map.removeImage(id); map.addImage(id, shapeImage("square", c[t], c.surface), { pixelRatio: 2 }); } for (const [id, img] of [ ["ai-diamond", shapeImage("diamond", c.accent, c.surface)], ["ixp-triangle", shapeImage("triangle", c.ink, c.surface)], ["cloud-ring", shapeImage("ring", c.accent, c.surface)], ["power-triangle", shapeImage("triangle", c.construction, c.surface)], ] as Array<[string, ImageData]>) { if (map.hasImage(id)) map.removeImage(id); map.addImage(id, img, { pixelRatio: 2 }); } // zoom-scaled radius from the per-feature `r` (computed at zoom ~10 basis) const rScaled: unknown = ["interpolate", ["linear"], ["zoom"], 3, ["*", ["get", "r"], 0.75], 8, ["*", ["get", "r"], 1], 14, ["*", ["get", "r"], 1.5]]; /* density grid */ if (!map.getLayer("density-fill")) 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] } }); 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 } }); if (!map.getLayer("density-label")) 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 } }); /* clusters */ if (!map.getLayer("cluster-halo")) map.addLayer({ id: "cluster-halo", type: "circle", source: "clusters", paint: { "circle-color": toneColor as never, "circle-opacity": 0.16, "circle-radius": ["+", ["get", "r"], 8] } }); if (!map.getLayer("cluster-circle")) 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 } }); if (!map.getLayer("cluster-count")) 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" } }); if (!map.getLayer("cluster-name")) 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 } }); /* facility points */ if (!map.getLayer("point-imprecise")) 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] } }); if (!map.getLayer("point-precise")) 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] } }); if (!map.getLayer("point-ai")) 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] } }); if (!map.getLayer("point-label")) 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 } }); /* overlay symbols (projects, IXPs, cloud regions, power) */ if (!map.getLayer("overlay-ring")) 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 } }); if (!map.getLayer("overlay-project")) 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 } }); if (!map.getLayer("overlay-ixp")) 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 } }); if (!map.getLayer("overlay-cloud")) 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 } }); if (!map.getLayer("overlay-power")) 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 } }); if (!map.getLayer("overlay-label")) 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 } }); /* legacy cloud layer (cr=1 toggle) */ if (!map.getLayer("cloud-square")) 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 } }); setReady(true); load(); }; map.on("style.load", setup); const emitView = () => { const cnt = map.getCenter(); cbRef.current.onViewChange?.({ lng: +cnt.lng.toFixed(4), lat: +cnt.lat.toFixed(4), zoom: +map.getZoom().toFixed(2) }); }; const load = () => { if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(async () => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const b = map.getBounds(); const zoom = map.getZoom(); const w = Math.max(-180, b.getWest()); const e = Math.min(180, b.getEast()); const bbox = zoom < 2.5 ? "-180,-85,180,85" : `${w.toFixed(4)},${b.getSouth().toFixed(4)},${e.toFixed(4)},${b.getNorth().toFixed(4)}`; const q = queryRef.current; const r = await apiGet("/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 }); if (ctrl.signal.aborted) return; if (!r.ok) { cbRef.current.onData?.(null, r.error); return; } applyData(r.data); cbRef.current.onData?.(r.data); }, preview ? 0 : 220); }; 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 }); const applyData = (d: MapResponse) => { const clusters = map.getSource("clusters") as GeoJSONSource | undefined; const points = map.getSource("points") as GeoJSONSource | undefined; const overlay = map.getSource("overlay") as GeoJSONSource | undefined; const dens = map.getSource("density") as GeoJSONSource | undefined; if (!clusters || !points || !overlay || !dens) return; // density grid if (d.mode === "density" && d.density?.cells.length) { const cells = d.density.cells; const lats = Array.from(new Set(cells.map((c) => c.lat))).sort((a, b) => a - b); const lngs = Array.from(new Set(cells.map((c) => c.lng))).sort((a, b) => a - b); const minDiff = (arr: number[], fb: number) => { let m = Infinity; for (let i = 1; i < arr.length; i++) m = Math.min(m, arr[i]! - arr[i - 1]!); return Number.isFinite(m) ? m : fb; }; const dh = minDiff(lats, 45); const dw = minDiff(lngs, 45); const max = Math.max(1e-9, d.density.max || Math.max(...cells.map((c) => c.w))); const isMw = d.density.view === "known_mw" || d.density.view === "pipeline_mw"; dens.setData({ type: "FeatureCollection", features: cells .filter((c) => c.w > 0 || c.n > 0) .map((c) => { const t = Math.sqrt(Math.max(0, c.w) / max); const label = isMw ? (c.w >= 1000 ? `${(c.w / 1000).toFixed(1)} GW` : `${Math.round(c.w)} MW`) : String(Math.round(c.w)); const s = Math.max(-85, c.lat - dh / 2); const n = Math.min(85, c.lat + dh / 2); 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 } }; }), }); } else dens.setData(EMPTY); if (d.mode === "clusters") { const max = Math.max(1, ...(d.clusters ?? []).map((c) => c.count)); clusters.setData({ type: "FeatureCollection", features: (d.clusters ?? []).map((c) => ({ type: "Feature", geometry: { type: "Point", coordinates: [c.lng, c.lat] }, 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 }, })), }); points.setData(EMPTY); } else { clusters.setData(EMPTY); const facilityPts = (d.points ?? []).filter((p) => !p.k || p.k === "facility"); const others = (d.points ?? []).filter((p) => p.k && p.k !== "facility"); points.setData({ type: "FeatureCollection", features: facilityPts.map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) }); const ov = [...(d.overlay ?? []), ...others]; overlay.setData({ type: "FeatureCollection", features: ov.map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) }); return; } overlay.setData({ type: "FeatureCollection", features: (d.overlay ?? []).map((p) => ({ type: "Feature", geometry: { type: "Point", coordinates: [p.lng, p.lat] }, properties: pointProps(p) })) }); }; if (interactive) { map.on("moveend", () => { emitView(); load(); }); map.on("click", "cluster-circle", (e: MapMouseEvent) => { const f = map.queryRenderedFeatures(e.point, { layers: ["cluster-circle"] })[0]; if (!f) return; const [lng, lat] = (f.geometry as Point).coordinates; map.easeTo({ center: [lng!, lat!], zoom: Math.min(17, map.getZoom() + (map.getZoom() < 4 ? 2.5 : 2)), duration: 500 }); }); map.on("click", "density-fill", (e: MapMouseEvent) => { map.easeTo({ center: e.lngLat, zoom: Math.min(17, map.getZoom() + 2), duration: 500 }); }); const pointLayers = ["point-precise", "point-imprecise", "point-ai", "overlay-project", "overlay-ixp", "overlay-cloud", "overlay-power", "overlay-ring"]; const onPoint = (e: MapMouseEvent) => { const f = map.queryRenderedFeatures(e.point, { layers: pointLayers.filter((l) => !!map.getLayer(l)) })[0]; if (!f) return; const p = f.properties as unknown as MapPoint & { tone: string; imprecise: number; k: string; ai: number }; 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 }); }; for (const l of pointLayers) map.on("click", l, onPoint); map.on("click", "cloud-square", (e: MapMouseEvent) => { const f = map.queryRenderedFeatures(e.point, { layers: ["cloud-square"] })[0]; if (!f) return; const id = (f.properties as { id: string }).id; const cr = cloudCache.current?.find((x) => x.id === id); if (cr) cbRef.current.onSelectCloudRegion?.(cr); }); for (const l of ["cluster-circle", ...pointLayers, "cloud-square", "density-fill"]) { map.on("mouseenter", l, () => (map.getCanvas().style.cursor = "pointer")); map.on("mouseleave", l, () => (map.getCanvas().style.cursor = "")); } } (map as MLMap & { __load?: () => void }).__load = load; return () => { abortRef.current?.abort(); if (timer.current) clearTimeout(timer.current); map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /* ---------- theme change → swap style (layers re-added on style.load) */ useEffect(() => { const map = mapRef.current; if (!map || !ready) return; const want = dark ? STYLE_DARK : STYLE_LIGHT; const cur = (map.getStyle() as { name?: string } | undefined)?.name ?? ""; if ((dark && /positron/i.test(cur)) || (!dark && !/positron/i.test(cur))) map.setStyle(want); }, [dark, ready]); /* ---------- filters / layer / density / year change → reload */ useEffect(() => { const map = mapRef.current as (MLMap & { __load?: () => void }) | null; if (!map || !ready) return; map.__load?.(); }, [querySig, ready]); /* ---------- cloud regions layer (legacy toggle) */ useEffect(() => { const map = mapRef.current; if (!map || !ready) return; const src = map.getSource("cloud") as GeoJSONSource | undefined; if (!src) return; if (!showCloudRegions) { src.setData(EMPTY); return; } const ctrl = new AbortController(); (async () => { if (!cloudCache.current) { const r = await apiGet("/cloud-regions", undefined, { base: clientApiBase(), signal: ctrl.signal }); if (!r.ok || ctrl.signal.aborted) return; cloudCache.current = Array.isArray(r.data) ? r.data : r.data.items; } src.setData({ type: "FeatureCollection", 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 } })), }); })(); return () => ctrl.abort(); }, [showCloudRegions, ready]); /* ---------- fly-to */ useEffect(() => { const map = mapRef.current; if (!map || !flyTo) return; const padding = { top: 40, left: 40, right: 40, bottom: 40 + paddingBottom }; if (flyTo.bbox) map.fitBounds(flyTo.bbox as LngLatBoundsLike, { padding, duration: 700, maxZoom: 12 }); else if (flyTo.center) map.flyTo({ center: flyTo.center, zoom: flyTo.zoom ?? 11, duration: 700, padding }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [flyTo?.key]); return
; }