spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { CloudRegionSummary, DensityView, FacilityFilters, FacilityStatus, MapLayer, MapPoint, MapResponse, SearchHit, SearchResponse } from "@dci/core";4import { DENSITY_VIEWS, MAP_LAYERS, PIPELINE_STATUSES } from "@dci/core";5import { ChevronRight, List, Locate, PanelRightClose, PanelRightOpen, SlidersHorizontal } from "lucide-react";6import dynamic from "next/dynamic";7import Link from "next/link";8import { useRouter, useSearchParams } from "next/navigation";9import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";10import { FacilityFilterFields } from "@/components/facility-filters";11import type { FlyTarget, MapExtraQuery, MapView } from "@/components/map/map-canvas";12import { DensityLegend, DensityToggle, LAYER_HINT, ModesRail, TimeMachineControl } from "@/components/map/map-modes";13import { AiMark, Badge, ConfidenceBadge, FlagBadges, PrecisionBadge, StatusBadge, TypeBadge } from "@/components/ui/badge";14import { Button, IconButton } from "@/components/ui/button";15import { cn } from "@/components/ui/cn";16import { CountryFlag } from "@/components/ui/country-flag";17import { FilterChips, ToggleChip, type Chip } from "@/components/ui/filter-chips";18import { SearchInput } from "@/components/ui/search-input";19import { Sheet } from "@/components/ui/sheet";20import { Skeleton } from "@/components/ui/skeleton";21import { StatusDot } from "@/components/ui/status-dot";22import { apiGet, clientApiBase } from "@/lib/api";23import { countryName, fmtInt, fmtMw, fmtPct } from "@/lib/format";24import { useDebounced, useMediaQuery } from "@/lib/hooks";25import { DENSITY_LABEL, MAP_LAYER_LABEL, PRECISION_LABEL, STATUS_LABEL, STATUS_SHORT, TYPE_LABEL } from "@/lib/labels";26import { countActiveFilters, describeFilters, filtersToQuery, parseFilters, removeFilter } from "@/lib/query";27import { routes } from "@/lib/routes";2829const MapCanvas = dynamic(() => import("@/components/map/map-canvas").then((m) => m.MapCanvas), { ssr: false, loading: () => <Skeleton className="h-full w-full rounded-none" /> });3031/* ------------------------------------------------------------------ URL state beyond FacilityFilters */3233interface MapExtras {34 layer: MapLayer;35 density: DensityView | null;36 year: number | null;37 location_precision?: string;38 ai_evidence?: string;39 project_status?: string;40 expected_from?: number;41 expected_to?: number;42}4344const PRECISION_GROUPS: Array<{ key: string; label: string; hint: string }> = [45 { key: "exact,parcel,street", label: "Precise", hint: "exact / parcel / street" },46 { key: "city,metro", label: "City / metro", hint: "placed at the city or metro point" },47 { key: "approximate,unknown", label: "Approximate", hint: "approximate or unknown" },48];49const AI_EVIDENCE = ["confirmed", "likely", "associated"] as const;50const STAGES: FacilityStatus[] = [...PIPELINE_STATUSES, "cancelled"];5152function parseExtras(sp: URLSearchParams): MapExtras {53 const layer = sp.get("layer");54 const density = sp.get("density");55 const year = Number(sp.get("year"));56 const num = (k: string) => {57 const v = Number(sp.get(k));58 return sp.get(k) && Number.isFinite(v) ? v : undefined;59 };60 return {61 layer: (MAP_LAYERS as readonly string[]).includes(layer ?? "") ? (layer as MapLayer) : "facilities",62 density: (DENSITY_VIEWS as readonly string[]).includes(density ?? "") ? (density as DensityView) : null,63 year: sp.get("year") && Number.isFinite(year) && year > 1900 ? year : null,64 location_precision: sp.get("location_precision") ?? undefined,65 ai_evidence: sp.get("ai_evidence") ?? undefined,66 project_status: sp.get("project_status") ?? undefined,67 expected_from: num("expected_from"),68 expected_to: num("expected_to"),69 };70}7172function extrasToQuery(x: MapExtras): MapExtraQuery {73 return { location_precision: x.location_precision, ai_evidence: x.ai_evidence, project_status: x.project_status, expected_from: x.expected_from, expected_to: x.expected_to };74}7576const LEGEND: Array<{ tone: Parameters<typeof StatusDot>[0]["status"]; label: string; hollow?: boolean }> = [77 { tone: "operational", label: "Operational" },78 { tone: "construction", label: "Under construction" },79 { tone: "planned", label: "Planned / announced" },80 { tone: "permitting", label: "Permitting / approved" },81 { tone: "delayed", label: "Delayed" },82 { tone: "closed", label: "Cancelled / closed" },83 { tone: "rumored", label: "Rumored" },84];8586/* ------------------------------------------------------------------ extra filter fields */8788function ExtraFilterFields({ value, onChange, layer }: { value: MapExtras; onChange: (x: MapExtras) => void; layer: MapLayer }) {89 const csv = (s: string | undefined) => (s ? s.split(",").filter(Boolean) : []);90 const toggleCsv = (key: "ai_evidence" | "project_status", member: string) => {91 const cur = csv(value[key]);92 const next = cur.includes(member) ? cur.filter((x) => x !== member) : [...cur, member];93 onChange({ ...value, [key]: next.length ? next.join(",") : undefined });94 };95 const field = "figure h-9 w-full rounded-sm border border-hair-2 bg-surface px-2 text-[13px] outline-none focus:border-ink";96 return (97 <div className="flex flex-col">98 <fieldset className="border-t border-hair py-3">99 <legend className="label-caps mb-2 block w-full p-0">Location precision</legend>100 <div className="flex flex-wrap gap-1.5">101 {PRECISION_GROUPS.map((g) => (102 <ToggleChip key={g.key} active={value.location_precision === g.key} onClick={() => onChange({ ...value, location_precision: value.location_precision === g.key ? undefined : g.key })} dot={<span className={cn("inline-block size-[8px] rounded-full", g.key.startsWith("exact") ? "bg-ink-2" : "border-[1.5px] border-ink-3")} />}>103 {g.label}104 </ToggleChip>105 ))}106 </div>107 <p className="mt-1.5 text-[11px] text-ink-4">Hollow rings mark city / metro / approximate points — never an exact site.</p>108 </fieldset>109 <fieldset className="border-t border-hair py-3">110 <legend className="label-caps mb-2 block w-full p-0">AI evidence</legend>111 <div className="flex flex-wrap gap-1.5">112 {AI_EVIDENCE.map((a) => (113 <ToggleChip key={a} active={csv(value.ai_evidence).includes(a)} onClick={() => toggleCsv("ai_evidence", a)}>114 {a}115 </ToggleChip>116 ))}117 </div>118 </fieldset>119 <fieldset className={cn("border-t border-hair py-3", layer !== "projects" && layer !== "pipeline" && "opacity-80")}>120 <legend className="label-caps mb-2 block w-full p-0">Project stage</legend>121 <div className="flex flex-wrap gap-1.5">122 {STAGES.map((s) => (123 <ToggleChip key={s} active={csv(value.project_status).includes(s)} onClick={() => toggleCsv("project_status", s)} dot={<StatusDot status={s} size={7} />}>124 {STATUS_SHORT[s]}125 </ToggleChip>126 ))}127 </div>128 </fieldset>129 <fieldset className="border-t border-hair py-3">130 <legend className="label-caps mb-2 block w-full p-0">Expected opening</legend>131 <div className="flex items-center gap-2">132 <label className="flex-1">133 <span className="sr-only">Expected from</span>134 <input type="number" inputMode="numeric" min={2000} max={2045} placeholder="from" value={value.expected_from ?? ""} onChange={(e) => onChange({ ...value, expected_from: e.target.value ? Number(e.target.value) : undefined })} className={field} />135 </label>136 <span className="text-ink-4">–</span>137 <label className="flex-1">138 <span className="sr-only">Expected to</span>139 <input type="number" inputMode="numeric" min={2000} max={2045} placeholder="to" value={value.expected_to ?? ""} onChange={(e) => onChange({ ...value, expected_to: e.target.value ? Number(e.target.value) : undefined })} className={field} />140 </label>141 </div>142 </fieldset>143 </div>144 );145}146147/* ------------------------------------------------------------------ page */148149export function MapPage() {150 const router = useRouter();151 const sp = useSearchParams();152 const isDesktop = useMediaQuery("(min-width: 768px)");153 const isWide = useMediaQuery("(min-width: 1180px)");154155 const filters = useMemo(() => parseFilters(sp), [sp]);156 const extras = useMemo(() => parseExtras(sp), [sp]);157 const showCloud = sp.get("cr") === "1";158 const initialView = useMemo<MapView | undefined>(() => {159 const z = Number(sp.get("z"));160 const lat = Number(sp.get("lat"));161 const lng = Number(sp.get("lng"));162 return Number.isFinite(z) && Number.isFinite(lat) && Number.isFinite(lng) && sp.get("z") ? { zoom: z, lat, lng } : undefined;163 // eslint-disable-next-line react-hooks/exhaustive-deps164 }, []);165166 const [panel, setPanel] = useState(false); // mobile filter sheet167 const [inspector, setInspector] = useState(true); // desktop right inspector168 const [inspectorTab, setInspectorTab] = useState<"filters" | "details">("filters");169 const [draft, setDraft] = useState<FacilityFilters>(filters);170 const [draftX, setDraftX] = useState<MapExtras>(extras);171 const [synced, setSynced] = useState(filters);172 const [syncedX, setSyncedX] = useState(extras);173 if (synced !== filters) {174 setSynced(filters);175 setDraft(filters);176 }177 if (syncedX !== extras) {178 setSyncedX(extras);179 setDraftX(extras);180 }181 const [selected, setSelected] = useState<MapPoint | null>(null);182 const [selectedCloud, setSelectedCloud] = useState<CloudRegionSummary | null>(null);183 const [data, setData] = useState<MapResponse | null>(null);184 const [error, setError] = useState<string | null>(null);185 const [fly, setFly] = useState<FlyTarget | null>(null);186 const [legend, setLegend] = useState(false);187188 // search box189 const [q, setQ] = useState(sp.get("q") ?? "");190 const dq = useDebounced(q.trim(), 180);191 const [hits, setHits] = useState<SearchHit[]>([]);192 const [hitsOpen, setHitsOpen] = useState(false);193 useEffect(() => {194 if (dq.length < 2) return;195 const ctrl = new AbortController();196 apiGet<SearchResponse>("/search", { q: dq }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 6000 }).then((r) => {197 if (!ctrl.signal.aborted && r.ok) setHits(r.data.hits.filter((h) => h.meta && (typeof h.meta.lat === "number" || Array.isArray(h.meta.bbox))).concat(r.data.hits.filter((h) => !(h.meta && (typeof h.meta.lat === "number" || Array.isArray(h.meta.bbox))))).slice(0, 8));198 });199 return () => ctrl.abort();200 }, [dq]);201202 /* URL writer: filters + extras via router, view via history.replaceState. */203 const writeUrl = useCallback(204 (f: FacilityFilters, x: MapExtras, extra?: Record<string, string | undefined>) => {205 const params = new URLSearchParams(filtersToQuery(f));206 if (x.layer !== "facilities") params.set("layer", x.layer);207 if (x.density) params.set("density", x.density);208 if (x.year !== null) params.set("year", String(x.year));209 for (const k of ["location_precision", "ai_evidence", "project_status"] as const) if (x[k]) params.set(k, x[k]!);210 for (const k of ["expected_from", "expected_to"] as const) if (x[k] !== undefined) params.set(k, String(x[k]));211 // the current view is kept in the URL by onViewChange (history.replaceState) — copy it forward212 const cur = new URLSearchParams(window.location.search);213 for (const k of ["z", "lat", "lng"]) {214 const v = cur.get(k);215 if (v !== null) params.set(k, v);216 }217 if (showCloud) params.set("cr", "1");218 if (extra) {219 for (const [k, val] of Object.entries(extra)) {220 if (val === undefined) params.delete(k);221 else params.set(k, val);222 }223 }224 const s = params.toString();225 router.replace(`${routes.map()}${s ? `?${s}` : ""}`, { scroll: false });226 },227 [router, showCloud],228 );229 const setExtras = useCallback((patch: Partial<MapExtras>) => writeUrl(filters, { ...extras, ...patch }), [writeUrl, filters, extras]);230231 const onViewChange = useCallback((v: MapView) => {232 const params = new URLSearchParams(window.location.search);233 params.set("z", String(v.zoom));234 params.set("lat", String(v.lat));235 params.set("lng", String(v.lng));236 window.history.replaceState(window.history.state, "", `${window.location.pathname}?${params.toString()}`);237 }, []);238239 const onData = useCallback((r: MapResponse | null, err?: string) => {240 setData(r);241 setError(r ? null : (err ?? "unavailable"));242 }, []);243244 const onSelect = useCallback((p: MapPoint) => {245 setSelectedCloud(null);246 setSelected(p);247 setInspectorTab("details");248 setInspector(true);249 }, []);250251 // hide mobile bottom nav while a sheet is open on the map252 useEffect(() => {253 const open = !isDesktop && (!!selected || !!selectedCloud || panel);254 if (open) document.body.setAttribute("data-hide-mobile-nav", "");255 else document.body.removeAttribute("data-hide-mobile-nav");256 return () => document.body.removeAttribute("data-hide-mobile-nav");257 }, [selected, selectedCloud, panel, isDesktop]);258259 const activeExtras = [extras.location_precision, extras.ai_evidence, extras.project_status, extras.expected_from, extras.expected_to].filter((v) => v !== undefined).length;260 const active = countActiveFilters(filters) + activeExtras;261 const chips: Chip[] = describeFilters(filters, { status: STATUS_LABEL, type: TYPE_LABEL }).map((c) => ({ key: `${c.key}:${c.member ?? c.value}`, label: c.label, value: c.value, onRemove: () => writeUrl(removeFilter(filters, c), extras) }));262 if (extras.location_precision) chips.push({ key: "lp", label: "Precision", value: PRECISION_GROUPS.find((g) => g.key === extras.location_precision)?.label ?? extras.location_precision, onRemove: () => setExtras({ location_precision: undefined }) });263 if (extras.ai_evidence) chips.push({ key: "aie", label: "AI evidence", value: extras.ai_evidence.replace(/,/g, ", "), onRemove: () => setExtras({ ai_evidence: undefined }) });264 if (extras.project_status) chips.push({ key: "ps", label: "Stage", value: extras.project_status.split(",").map((s) => STATUS_SHORT[s as FacilityStatus] ?? s).join(", "), onRemove: () => setExtras({ project_status: undefined }) });265 if (extras.expected_from !== undefined || extras.expected_to !== undefined) chips.push({ key: "exp", label: "Expected", value: `${extras.expected_from ?? "…"}–${extras.expected_to ?? "…"}`, onRemove: () => setExtras({ expected_from: undefined, expected_to: undefined }) });266 if (extras.year !== null) chips.push({ key: "year", label: "Known open by", value: String(extras.year), onRemove: () => setExtras({ year: null }) });267268 const pickHit = (h: SearchHit) => {269 setHitsOpen(false);270 setQ(h.title);271 const m = h.meta ?? {};272 if (Array.isArray(m.bbox) && m.bbox.length === 4) setFly({ bbox: m.bbox as [number, number, number, number], key: Date.now() });273 else if (typeof m.lat === "number" && typeof m.lng === "number") setFly({ center: [m.lng, m.lat], zoom: h.type === "facility" ? 13 : h.type === "country" ? 5 : 10, key: Date.now() });274 else if (h.type === "country") writeUrl({ ...filters, country: (m.iso2 as string | undefined) ?? filters.country }, extras);275 else if (h.type === "operator") writeUrl({ ...filters, operator: h.slug }, extras);276 else if (h.type === "metro") writeUrl({ ...filters, metro: h.slug }, extras);277 else writeUrl({ ...filters, q: h.title }, extras);278 };279280 const total = data?.total ?? null;281 const yearCoverage = data?.yearCoverage ?? null;282 const densityMax = data?.density?.max ?? null;283284 const statusLine = error ? "Data unavailable" : total === null ? "Loading…" : data?.mode === "density" ? `${fmtInt(total)} records · ${DENSITY_LABEL[data.density?.view ?? "facilities"].toLowerCase()}` : `${fmtInt(total)} ${extras.layer === "facilities" || extras.layer === "capacity" ? "facilities" : MAP_LAYER_LABEL[extras.layer].toLowerCase()}${data?.mode === "points" ? " in view" : ""}${data?.degraded ? " (clustered)" : ""}`;285286 const facilityFields = (onChange: (n: FacilityFilters) => void, value: FacilityFilters, extraNode?: ReactNode) => (287 <FacilityFilterFields288 value={value}289 onChange={onChange}290 hide={["q"]}291 extra={292 <div className="mt-2 flex flex-wrap gap-1.5">293 <ToggleChip active={showCloud} onClick={() => writeUrl(value, extras, { cr: showCloud ? undefined : "1" })} dot={<span className="inline-block size-[9px] rounded-full border-2 border-accent" />}>294 Cloud regions overlay295 </ToggleChip>296 {extraNode}297 </div>298 }299 />300 );301302 const legendPanel = (303 <div className="text-[11.5px]">304 <ul className="grid grid-cols-2 gap-x-4 gap-y-1">305 {LEGEND.map((l) => (306 <li key={l.label} className="inline-flex items-center gap-1.5 text-ink-2">307 <StatusDot status={l.tone} hollow={l.hollow} size={8} /> {l.label}308 </li>309 ))}310 </ul>311 <ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 border-t border-hair pt-2 text-ink-2">312 <li className="inline-flex items-center gap-1.5">313 <span className="inline-flex items-end gap-[2px]" aria-hidden>314 <span className="inline-block size-[5px] rounded-full bg-ink-3" />315 <span className="inline-block size-[8px] rounded-full bg-ink-3" />316 <span className="inline-block size-[11px] rounded-full bg-ink-3" />317 </span>318 Size = known MW319 </li>320 <li className="inline-flex items-center gap-1.5">321 <span className="inline-block size-[10px] rounded-full border-[1.5px] border-ink-3" aria-hidden /> City / metro ring322 </li>323 <li className="inline-flex items-center gap-1.5">324 <span className="inline-block size-[8px] rotate-45 bg-accent" aria-hidden /> AI / HPC325 </li>326 <li className="inline-flex items-center gap-1.5">327 <span className="inline-block size-[9px] bg-st-construction" aria-hidden /> Project (stage colour)328 </li>329 <li className="inline-flex items-center gap-1.5">330 <span aria-hidden className="inline-block border-x-[5px] border-b-[9px] border-x-transparent border-b-ink" /> Internet exchange331 </li>332 <li className="inline-flex items-center gap-1.5">333 <span className="inline-block size-[9px] rounded-full border-2 border-accent" aria-hidden /> Cloud region334 </li>335 <li className="inline-flex items-center gap-1.5">336 <span className="inline-flex size-[12px] items-center justify-center rounded-full bg-ink-3 text-[7px] text-white" aria-hidden>337 n338 </span>339 Cluster (count)340 </li>341 </ul>342 {extras.density && (343 <div className="mt-2 border-t border-hair pt-2">344 <DensityLegend density={extras.density} max={densityMax} />345 </div>346 )}347 </div>348 );349350 const railW = isWide ? 176 : 56;351 const inspW = 340;352 const leftOffset = isDesktop ? railW + 12 : 0;353 const rightOffset = isDesktop && inspector ? inspW + 12 : 0;354355 return (356 <div className="relative h-full w-full">357 <h1 className="sr-only">Global map of data center infrastructure</h1>358 <div className="absolute inset-0">359 <MapCanvas360 filters={filters}361 layer={extras.layer}362 density={extras.density ?? undefined}363 year={extras.year ?? undefined}364 extraQuery={extrasToQuery(extras)}365 showCloudRegions={showCloud}366 initialView={initialView}367 onViewChange={onViewChange}368 onData={onData}369 onSelect={onSelect}370 onSelectCloudRegion={(c) => {371 setSelected(null);372 setSelectedCloud(c);373 setInspectorTab("details");374 setInspector(true);375 }}376 flyTo={fly}377 paddingBottom={isDesktop ? 0 : 260}378 />379 </div>380381 {/* ---------------------------------------------------------------- desktop left rail: modes · density · time machine */}382 {isDesktop && (383 <aside className="absolute bottom-3 left-3 top-3 z-[10] flex flex-col overflow-y-auto rounded-sm border border-hair bg-surface/95 backdrop-blur no-scrollbar" style={{ width: railW }} aria-label="Map modes and time machine">384 {isWide ? (385 <>386 <ModesRail layer={extras.layer} onChange={(l) => setExtras({ layer: l })} className="px-1.5 pb-1.5" />387 <DensityToggle density={extras.density} onChange={(d) => setExtras({ density: d })} className="border-t border-hair px-1.5 pb-1.5" />388 <TimeMachineControl year={extras.year} onChange={(y) => setExtras({ year: y })} coverage={yearCoverage} className="mt-auto border-t border-hair pb-2" />389 </>390 ) : (391 <div className="flex flex-col gap-0.5 p-1">392 {MAP_LAYERS.map((l) => (393 <button key={l} type="button" aria-pressed={l === extras.layer} title={`${MAP_LAYER_LABEL[l]} — ${LAYER_HINT[l]}`} onClick={() => setExtras({ layer: l })} className={cn("figure inline-flex h-9 w-full items-center justify-center rounded-sm text-[10.5px] font-medium uppercase", l === extras.layer ? "bg-ink text-paper" : "text-ink-2 hover:bg-surface-2")}>394 {MAP_LAYER_LABEL[l].slice(0, 3)}395 </button>396 ))}397 <button type="button" aria-pressed={!!extras.density} title="Density" onClick={() => setExtras({ density: extras.density ? null : "facilities" })} className={cn("mt-1 inline-flex h-9 w-full items-center justify-center rounded-sm border-t border-hair text-[10.5px] font-medium uppercase", extras.density ? "bg-surface-3 text-ink" : "text-ink-2 hover:bg-surface-2")}>398 Dens399 </button>400 </div>401 )}402 </aside>403 )}404405 {/* ---------------------------------------------------------------- desktop right inspector: filters · details */}406 {isDesktop && (407 <aside className={cn("absolute bottom-3 right-3 top-3 z-[10] flex flex-col rounded-sm border border-hair bg-surface/95 backdrop-blur transition-transform", !inspector && "translate-x-[calc(100%+12px)]")} style={{ width: inspW }} aria-label="Map inspector" aria-hidden={!inspector} id="map-inspector">408 <div className="flex items-center gap-1 border-b border-hair px-2 py-1.5">409 <div role="tablist" aria-label="Inspector" className="flex gap-0.5">410 {(["filters", "details"] as const).map((t) => (411 <button key={t} role="tab" type="button" aria-selected={inspectorTab === t} onClick={() => setInspectorTab(t)} className={cn("inline-flex h-8 items-center gap-1.5 rounded-sm px-2.5 text-[12.5px] font-medium", inspectorTab === t ? "bg-surface-3 text-ink" : "text-ink-3 hover:text-ink")}>412 {t === "filters" ? "Filters" : "Details"}413 {t === "filters" && active > 0 && <span className="figure rounded-xs bg-ink px-1 text-[10.5px] text-paper">{active}</span>}414 </button>415 ))}416 </div>417 <span className="figure ml-auto truncate text-[11px] text-ink-3">{statusLine}</span>418 <IconButton label="Collapse inspector" size="sm" onClick={() => setInspector(false)}>419 <PanelRightClose className="size-4" />420 </IconButton>421 </div>422 <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">423 {inspectorTab === "filters" ? (424 <>425 {active > 0 && (426 <div className="mb-2 flex justify-end">427 <Button size="xs" variant="ghost" onClick={() => writeUrl({}, { layer: extras.layer, density: extras.density, year: null })}>428 Reset filters429 </Button>430 </div>431 )}432 {facilityFields((n) => writeUrl(n, extras), filters)}433 <ExtraFilterFields value={extras} onChange={(x) => writeUrl(filters, x)} layer={extras.layer} />434 </>435 ) : selected ? (436 <QuickProfile p={selected} onClose={() => setSelected(null)} />437 ) : selectedCloud ? (438 <CloudProfile c={selectedCloud} />439 ) : (440 <div className="text-[12.5px] text-ink-3">441 <p>Select a facility, project, IXP or cloud region on the map to inspect it here.</p>442 <p className="mt-3 text-[11.5px] text-ink-4">{LAYER_HINT[extras.layer]}.</p>443 </div>444 )}445 </div>446 <div className="flex items-center justify-between border-t border-hair px-3 py-2 text-[11.5px] text-ink-3">447 <Link href={`${routes.datacenters()}${filtersToQuery(filters) ? `?${filtersToQuery(filters)}` : ""}`} className="inline-flex items-center gap-1 hover:text-ink">448 <List className="size-3.5" /> Open as list449 </Link>450 <button type="button" onClick={() => setLegend((l) => !l)} aria-expanded={legend} className="inline-flex items-center gap-1 hover:text-ink">451 Legend <ChevronRight className={cn("size-3.5 transition-transform", legend && "rotate-90")} />452 </button>453 </div>454 {legend && <div className="border-t border-hair px-3 py-2.5 animate-fade-in">{legendPanel}</div>}455 </aside>456 )}457 {isDesktop && !inspector && (458 <div className="absolute right-3 top-3 z-[11]">459 <Button size="md" variant={active ? "primary" : "secondary"} onClick={() => setInspector(true)} className="h-10 bg-surface/95 backdrop-blur" aria-expanded={inspector} aria-controls="map-inspector">460 <PanelRightOpen className="size-4" strokeWidth={1.75} /> Inspector{active > 0 && <span className="figure ml-0.5 rounded-xs bg-paper/20 px-1 text-[11px]">{active}</span>}461 </Button>462 </div>463 )}464465 {/* ---------------------------------------------------------------- top bar: search + chips (+ mobile modes) */}466 <div className="absolute top-3 z-[11] flex flex-col gap-2 transition-[left,right]" style={{ left: 12 + leftOffset, right: 12 + rightOffset }}>467 <div className="flex items-start gap-2">468 <div className="relative w-full max-w-[420px]">469 <SearchInput470 value={q}471 onChange={(v) => {472 setQ(v);473 setHitsOpen(true);474 }}475 onFocus={() => setHitsOpen(true)}476 onBlur={() => setTimeout(() => setHitsOpen(false), 120)}477 onKeyDown={(e) => {478 if (e.key === "Enter" && hits[0]) pickHit(hits[0]);479 if (e.key === "Escape") setHitsOpen(false);480 }}481 placeholder="Search a place, operator or facility…"482 aria-label="Search the map"483 wrapperClassName="bg-surface/95 backdrop-blur"484 trailing={485 !isDesktop && (486 <button type="button" onClick={() => setPanel(true)} aria-label={`Filters${active ? ` (${active} active)` : ""}`} className={cn("inline-flex h-10 min-w-10 items-center justify-center gap-1 rounded-xs px-2 text-[12px]", active ? "bg-ink text-paper" : "text-ink-2")}>487 <SlidersHorizontal className="size-4" strokeWidth={1.75} />488 {active > 0 && <span className="figure">{active}</span>}489 </button>490 )491 }492 />493 {hitsOpen && dq.length >= 2 && hits.length > 0 && (494 <ul role="listbox" className="absolute inset-x-0 top-full mt-1 max-h-[50vh] overflow-y-auto rounded-sm border border-hair-2 bg-surface animate-fade-in">495 {hits.map((h) => (496 <li key={`${h.type}:${h.id}`}>497 <button type="button" role="option" aria-selected={false} onMouseDown={(e) => e.preventDefault()} onClick={() => pickHit(h)} className="flex min-h-11 w-full items-center gap-3 px-3 py-1.5 text-left hover:bg-surface-2">498 <Badge tone="outline" size="xs" className="w-[78px] justify-center">499 {h.type.replace("_", " ")}500 </Badge>501 <span className="min-w-0 flex-1">502 <span className="block truncate text-[13px]">{h.title}</span>503 {h.subtitle && <span className="block truncate text-[11px] text-ink-3">{h.subtitle}</span>}504 </span>505 <Locate className="size-3.5 text-ink-4" />506 </button>507 </li>508 ))}509 </ul>510 )}511 </div>512 </div>513 {!isDesktop && <ModesRail layer={extras.layer} onChange={(l) => setExtras({ layer: l })} orientation="horizontal" className="-mx-3 px-3" />}514 {data && total === 0 && !error && (515 <p role="status" className="max-w-[420px] rounded-sm border border-hair bg-surface/95 px-3 py-2 text-[12px] text-ink-2 backdrop-blur animate-fade-in">516 {extras.layer === "projects" || extras.layer === "pipeline" ? (517 <>518 No project with published coordinates in this view — most projects carry a city-level location only.{" "}519 <Link href={routes.projects("view=table")} className="underline decoration-hair-2 hover:text-ink">520 Browse the project list521 </Link>522 .523 </>524 ) : extras.year !== null ? (525 <>No facility with a known opening date on or before {extras.year} in this view — opening dates are published for a small share of facilities.</>526 ) : (527 <>Nothing published for the {MAP_LAYER_LABEL[extras.layer].toLowerCase()} layer in this view{active ? " with these filters" : ""}.</>528 )}529 </p>530 )}531 {(chips.length > 0 || showCloud) && (532 <FilterChips chips={chips} onReset={() => writeUrl({}, { layer: extras.layer, density: null, year: null })} className="[&>*]:bg-surface/95 [&>*]:backdrop-blur">533 {showCloud && (534 <button type="button" onClick={() => writeUrl(filters, extras, { cr: undefined })} className="inline-flex h-10 shrink-0 items-center gap-1 whitespace-nowrap rounded-sm border border-accent/40 bg-surface px-2 text-[12px] text-ink sm:h-7">535 <span className="inline-block size-[8px] rounded-full border-2 border-accent" /> Cloud regions ×536 </button>537 )}538 </FilterChips>539 )}540 </div>541542 {/* ---------------------------------------------------------------- bottom-left: count + legend (+ mobile time machine) */}543 <div className="absolute z-[10] flex flex-col items-start gap-1.5 transition-[left] bottom-[calc(3.5rem+env(safe-area-inset-bottom)+12px)] md:bottom-3 [body[data-hide-mobile-nav]_&]:bottom-3" style={{ left: 12 + leftOffset }}>544 {legend && !isDesktop && <div className="max-w-[calc(100vw-24px)] rounded-sm border border-hair bg-surface/95 p-2.5 backdrop-blur animate-fade-in">{legendPanel}</div>}545 {!isDesktop && extras.year !== null && (546 <div className="w-[min(360px,calc(100vw-24px))] rounded-sm border border-hair bg-surface/95 backdrop-blur">547 <TimeMachineControl year={extras.year} onChange={(y) => setExtras({ year: y })} coverage={yearCoverage} compact />548 </div>549 )}550 <div className="flex flex-wrap items-center gap-1.5">551 {!isDesktop && (552 <button type="button" onClick={() => setLegend((l) => !l)} aria-expanded={legend} className="inline-flex h-10 items-center gap-1.5 rounded-sm border border-hair bg-surface/95 px-2.5 text-[12px] text-ink-2 backdrop-blur hover:text-ink">553 <span className="inline-flex gap-[2px]">554 <StatusDot status="operational" size={6} />555 <StatusDot status="construction" size={6} />556 <StatusDot status="planned" size={6} />557 </span>558 Legend559 </button>560 )}561 <span className="figure inline-flex h-10 items-center rounded-sm border border-hair bg-surface/95 px-2.5 text-[12px] text-ink-2 backdrop-blur md:h-8" aria-live="polite">562 {statusLine}563 </span>564 {extras.year !== null && isDesktop && <span className="inline-flex h-8 items-center rounded-sm border border-accent/40 bg-surface/95 px-2.5 text-[12px] text-ink-2 backdrop-blur">Known open by <span className="figure ml-1 text-ink">{extras.year}</span>{yearCoverage !== null && <span className="ml-1.5 text-ink-4">· dates known for {fmtPct(yearCoverage, yearCoverage < 0.1 ? 1 : 0)}</span>}</span>}565 </div>566 </div>567568 {/* ---------------------------------------------------------------- mobile filter sheet: filters + density + time machine */}569 {!isDesktop && (570 <Sheet571 open={panel}572 onClose={() => setPanel(false)}573 title="Filter the map"574 description={total !== null ? statusLine : undefined}575 heightVh={84}576 id="map-filters"577 footer={578 <div className="flex items-center justify-between gap-2">579 <Button580 variant="ghost"581 onClick={() => {582 setDraft({});583 setDraftX({ layer: draftX.layer, density: null, year: null });584 }}585 >586 Reset587 </Button>588 <Button589 variant="primary"590 onClick={() => {591 writeUrl(draft, draftX);592 setPanel(false);593 }}594 >595 Show results596 </Button>597 </div>598 }599 >600 <div className="-mx-1 mb-3 rounded-sm border border-hair">601 <TimeMachineControl year={draftX.year} onChange={(y) => setDraftX({ ...draftX, year: y })} coverage={yearCoverage} />602 </div>603 <div className="mb-3">604 <div className="label-caps mb-2">Density</div>605 <div className="flex flex-wrap gap-1.5">606 {DENSITY_VIEWS.map((d) => (607 <ToggleChip key={d} active={draftX.density === d} onClick={() => setDraftX({ ...draftX, density: draftX.density === d ? null : d })}>608 {DENSITY_LABEL[d]}609 </ToggleChip>610 ))}611 </div>612 </div>613 {facilityFields(setDraft, draft)}614 <ExtraFilterFields value={draftX} onChange={setDraftX} layer={draftX.layer} />615 </Sheet>616 )}617618 {/* ---------------------------------------------------------------- mobile detail sheets (swipeable) */}619 {!isDesktop && (620 <>621 <Sheet open={!!selected} onClose={() => setSelected(null)} modal={false} heightVh={48} width={380} title={selected?.n} description={selected ? [selected.o, selected.c ? countryName(selected.c) : null].filter(Boolean).join(" · ") : undefined}>622 {selected && <QuickProfile p={selected} />}623 </Sheet>624 <Sheet open={!!selectedCloud} onClose={() => setSelectedCloud(null)} modal={false} heightVh={40} width={380} title={selectedCloud ? `${selectedCloud.provider.name} · ${selectedCloud.code}` : undefined} description={selectedCloud?.name}>625 {selectedCloud && <CloudProfile c={selectedCloud} />}626 </Sheet>627 </>628 )}629 </div>630 );631}632633/* ------------------------------------------------------------------ profiles */634635function kindLabel(k: MapPoint["k"]): string {636 switch (k) {637 case "project":638 return "Project";639 case "ixp":640 return "Internet exchange";641 case "cloud_region":642 return "Cloud region";643 case "landing_station":644 return "Cable landing station";645 case "substation":646 return "Substation";647 case "power_plant":648 return "Power plant";649 default:650 return "Data center";651 }652}653654function hrefFor(p: MapPoint): string {655 switch (p.k) {656 case "project":657 return routes.project(p.slug);658 case "ixp":659 return routes.ixp(p.slug);660 case "cloud_region":661 return routes.cloudRegion(p.slug);662 default:663 return routes.facility(p.slug);664 }665}666667function QuickProfile({ p, onClose }: { p: MapPoint; onClose?: () => void }) {668 const imprecise = p.p === "city" || p.p === "metro" || p.p === "approximate" || p.p === "unknown";669 return (670 <div className="flex flex-col gap-3 text-[13px]">671 {onClose && (672 <div className="flex items-start justify-between gap-2">673 <div className="min-w-0">674 <div className="label-caps">{kindLabel(p.k)}</div>675 <div className="truncate text-[15px] font-semibold text-ink">{p.n}</div>676 {(p.o || p.c) && <div className="truncate text-[12px] text-ink-3">{[p.o, p.c ? countryName(p.c) : null].filter(Boolean).join(" · ")}</div>}677 </div>678 <Button size="xs" variant="ghost" onClick={onClose}>679 Clear680 </Button>681 </div>682 )}683 <div className="flex flex-wrap gap-1.5">684 <StatusBadge status={p.s} size="xs" />685 {p.k !== "project" && p.k !== "ixp" && <TypeBadge type={p.t} size="xs" />}686 {p.ai ? <AiMark /> : null}687 <FlagBadges isHyperscale={!!p.hs} />688 <PrecisionBadge precision={p.p} size="xs" />689 {p.rs === "campus" && (690 <Badge tone="outline" size="xs">691 Campus692 </Badge>693 )}694 </div>695 <dl className="grid grid-cols-3 gap-x-3 gap-y-2">696 <div>697 <dt className="label-caps">{p.k === "project" ? "Planned" : "Capacity"}</dt>698 <dd className="figure mt-0.5 text-[16px] font-medium">{p.mw === null || p.mw === undefined ? <span className="text-ink-4">—</span> : `${p.k === "project" ? "≈ " : ""}${fmtMw(p.mw)}`}</dd>699 {(p.mw === null || p.mw === undefined) && <dd className="text-[10.5px] text-ink-4">not published</dd>}700 </div>701 <div>702 <dt className="label-caps">Status</dt>703 <dd className="mt-0.5 inline-flex items-center gap-1.5">704 <StatusDot status={p.s} size={7} /> {STATUS_SHORT[p.s]}705 </dd>706 </div>707 <div>708 <dt className="label-caps">{p.y ? "Opened" : "Country"}</dt>709 <dd className="mt-0.5">{p.y ? <span className="figure">{p.y}</span> : <CountryFlag iso2={p.c} withName />}</dd>710 </div>711 </dl>712 <p className="text-[11.5px] text-ink-3">713 {PRECISION_LABEL[p.p]}714 {imprecise ? " — the ring marks the area, not a site" : ` · ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`}715 </p>716 <div className="flex items-center gap-2">717 <Button href={hrefFor(p)} variant="primary" size="md" className="flex-1">718 Open {p.k === "project" ? "project" : p.k === "ixp" ? "exchange" : "profile"}719 </Button>720 {p.o && (721 <Button href={routes.search(p.o)} size="md" variant="secondary" className="max-w-[45%] truncate">722 {p.o}723 </Button>724 )}725 </div>726 <p className="text-[11px] text-ink-4">727 Confidence and per-field evidence: <ConfidenceBadge level="moderate" size="xs" className="align-middle" /> on the full profile (every figure opens its sources).728 </p>729 </div>730 );731}732733function CloudProfile({ c }: { c: CloudRegionSummary }) {734 return (735 <div className="flex flex-col gap-3 text-[13px]">736 <div>737 <div className="label-caps">Cloud region</div>738 <div className="text-[15px] font-semibold text-ink">739 {c.provider.name} · <span className="figure">{c.code}</span>740 </div>741 <div className="text-[12px] text-ink-3">{c.name}</div>742 </div>743 <div className="flex flex-wrap gap-1.5">744 <Badge tone="outline" size="xs">745 {c.status}746 </Badge>747 {c.isSovereign && (748 <Badge tone="accent" size="xs">749 Sovereign750 </Badge>751 )}752 <PrecisionBadge precision={c.geoPrecision} size="xs" />753 </div>754 <dl className="grid grid-cols-2 gap-x-4 gap-y-2">755 <div>756 <dt className="label-caps">Location</dt>757 <dd className="mt-0.5 inline-flex items-center gap-1.5">758 <CountryFlag iso2={c.countryIso2} size={12} /> {c.city ?? countryName(c.countryIso2)}759 </dd>760 </div>761 <div>762 <dt className="label-caps">Availability zones</dt>763 <dd className="figure mt-0.5">{c.availabilityZones ?? "—"}</dd>764 </div>765 </dl>766 <p className="text-[11.5px] text-ink-3">Providers publish the metro, not the building — the point marks the market.</p>767 <Button href={routes.cloudRegion(c.slug)} variant="primary" size="md" className="w-full">768 Open region769 </Button>770 </div>771 );772}773