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