Files
beacon-web/src/features/map/MapView.tsx
T
MrAlders0n 464e701f76 Add a toggleable IATA border layer to the map
Draw each active IATA's GeoJSON region border as an outline over a faint
fill beneath the node markers, behind a Map Settings toggle that's off by
default and shareable via a URL param. IATAs with no border answer 204
and are skipped.
2026-07-24 22:21:12 -04:00

244 lines
13 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import "maplibre-gl/dist/maplibre-gl.css";
import { useMapLibre } from "./useMapLibre";
import { useMapNodes } from "./useMapNodes";
import { useMapNeighbors } from "./useMapNeighbors";
import { useMapBorders } from "./useMapBorders";
import { useMapBordersData } from "./useMapBordersData";
import { useMapPacketFlow } from "./useMapPacketFlow";
import { PacketFlowButton } from "./PacketFlowButton";
import { useMapNodesData } from "./useMapNodesData";
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson";
import { MapSettingsPanel } from "./MapSettingsPanel";
import { parseMapView, buildMapParams, type MapViewSnapshot } from "./map-url";
import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, MAP_BORDERS_STORAGE_KEY, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types";
import type { FeatureCollection, LineString } from "geojson";
import { EmptyState } from "../../components/EmptyState";
import { LoadingPill } from "../../components/LoadingPill";
import { useRegion } from "../../hooks/useRegion";
import { useTheme } from "../../hooks/useTheme";
import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers";
import { getIatas, getNodeNeighbors } from "../../api/client";
import { upsertNodePages } from "../nodes/node-updates";
import type { WsManager } from "../../api/ws-manager";
import type { NodeSummary } from "../nodes/types";
import type { CursorPage } from "../../types/api";
import type { WsNodeUpdate } from "../../types/ws";
const EMPTY_EDGES: FeatureCollection<LineString, NeighborEdgeProps> = { type: "FeatureCollection", features: [] };
interface MapViewProps {
wsManager: WsManager;
// shared with the Nodes tab (lifted to AppInner) so the open NodeDetailPanel stays live
selectedNodeId: string | null;
onSelectNode: (id: string) => void;
}
export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProps) {
// Deep-link params, read once at mount (like the region's ?iata seed). Each setting below is seeded
// URL -> localStorage -> default; the URL wins for this session but is never written back to
// localStorage, so a shared link can't clobber the visitor's saved prefs. See docs/superpowers/specs.
const [searchParams] = useSearchParams();
const [urlView] = useState(() => parseMapView(searchParams));
// restore the saved style; resolveMapStyle falls back to the default if the stored id is stale
const [styleId, setStyleId] = useState(
() => urlView.styleId ?? resolveMapStyle(localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID).id,
);
const handleStyleChange = useCallback((id: string) => {
setStyleId(id);
localStorage.setItem(MAP_STYLE_STORAGE_KEY, id);
}, []);
// A basemap that fails to load (offline / 5xx) reverts the selection to the last style that loaded,
// so the switcher matches the still-rendered map and the failed choice isn't persisted.
const handleStyleError = useCallback((lastGoodStyleId: string) => {
setStyleId(lastGoodStyleId);
localStorage.setItem(MAP_STYLE_STORAGE_KEY, lastGoodStyleId);
}, []);
const [typeFilter, setTypeFilter] = useState(
() => urlView.nodeType ?? localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY) ?? "",
); // "" = All
const handleTypeChange = useCallback((t: string) => {
setTypeFilter(t);
localStorage.setItem(MAP_NODE_TYPE_STORAGE_KEY, t);
}, []);
const [clustered, setClustered] = useState(
() => urlView.clustered ?? localStorage.getItem(MAP_CLUSTER_STORAGE_KEY) !== "off",
);
const handleClusteredChange = useCallback((c: boolean) => {
setClustered(c);
localStorage.setItem(MAP_CLUSTER_STORAGE_KEY, c ? "on" : "off");
}, []);
const [neighborLines, setNeighborLines] = useState<NeighborLinesMode>(() => {
if (urlView.neighborLines) return urlView.neighborLines;
const stored = localStorage.getItem(MAP_NEIGHBOR_LINES_STORAGE_KEY);
return stored === "on" || stored === "selected" || stored === "off" ? stored : "selected";
});
const handleNeighborLinesChange = useCallback((mode: NeighborLinesMode) => {
setNeighborLines(mode);
localStorage.setItem(MAP_NEIGHBOR_LINES_STORAGE_KEY, mode);
}, []);
// live packet-flow animation: opt-in per session (off by default, not persisted; a deep link can seed it)
const [packetFlow, setPacketFlow] = useState(() => urlView.flow ?? false);
// IATA region borders overlay, off by default; seeded URL -> localStorage like the other toggles
const [borders, setBorders] = useState(() => urlView.borders ?? localStorage.getItem(MAP_BORDERS_STORAGE_KEY) === "on");
const handleBordersChange = useCallback((on: boolean) => {
setBorders(on);
localStorage.setItem(MAP_BORDERS_STORAGE_KEY, on ? "on" : "off");
}, []);
// A deep-link camera opens the map here and suppresses the initial region fit (see useMapLibre).
const initialCamera = useMemo(
() => (urlView.center ? { center: urlView.center, zoom: urlView.zoom ?? DEFAULT_ZOOM } : undefined),
[urlView],
);
const { iatas: selectedIatas, regionKey } = useRegion();
const queryClient = useQueryClient();
// marker/cluster icons are canvas-drawn from the active --palette-* vars, so useMapNodes has to
// re-register them whenever the palette changes: on a theme switch, and once on load when the async
// themes populate (from [] -> filled).
const { themeId, themes } = useTheme();
const themeKey = themes.length ? themeId : "";
const { data: iatas } = useQuery({ queryKey: ["iatas"], queryFn: getIatas, staleTime: 60_000 });
// nodes for the selected region (its own key, independent of the Nodes-table filters/page cap).
// Pages in 50 at a time so the map fills batch by batch; nodesKey matches the hook's query key.
const nodesKey = useMemo(() => ["map-nodes", regionKey], [regionKey]);
const { nodes, loadedCount, isPaging, isError: nodesError } = useMapNodesData(selectedIatas, regionKey);
// patch-or-insert the live update into the paged node cache (the shared helper preserves refs
// when nothing changed, so a same-values re-advert doesn't trigger a full map repaint); brand-new
// nodes are appended from the event itself since the cache never refetches on its own.
const handleNodeUpdate = useCallback(
(data: WsNodeUpdate["data"]) => {
queryClient.setQueryData<InfiniteData<CursorPage<NodeSummary>>>(nodesKey, (old) =>
upsertNodePages(old, data),
);
// mirror NodeTable: refresh the shared detail panel when the open node changes live
if (selectedNodeId === data.nodeId) {
queryClient.invalidateQueries({ queryKey: ["node", data.nodeId] });
}
},
[queryClient, nodesKey, selectedNodeId],
);
useWsNodeUpdateHandler(wsManager, handleNodeUpdate);
// split memos: rebuild the FeatureCollection only when nodes change; a type-filter change just
// re-filters the already-built collection instead of re-running the full transform over all nodes
const baseFc = useMemo(() => nodesToFeatureCollection(nodes), [nodes]);
const geojson = useMemo(() => filterByNodeType(baseFc, typeFilter), [baseFc, typeFilter]);
// Selected mode colours the one node's edges by observation count + freshness, which only the node
// detail endpoint carries (the list's neighborIds are bare uuids). Shares the panel's query cache
// (same key), so selecting a node — which opens the panel — usually has this already warm.
const { data: focusNeighbors } = useQuery({
queryKey: ["node-neighbors", selectedNodeId],
queryFn: () => getNodeNeighbors(selectedNodeId!),
enabled: neighborLines === "selected" && !!selectedNodeId,
staleTime: 30_000,
});
// "on" is a pure client-side render over already-loaded nodes (neighborIds ship with every map
// page) so it never refetches; "selected" colours the detail edges; "off" short-circuits to none.
const neighborEdges = useMemo(() => {
if (neighborLines === "off") return EMPTY_EDGES;
if (neighborLines === "selected") {
if (!selectedNodeId) return EMPTY_EDGES;
return buildFocusedNeighborEdges(nodes.find((n) => n.id === selectedNodeId), focusNeighbors ?? []);
}
return buildNeighborEdges(nodes, "on", selectedNodeId);
}, [nodes, neighborLines, selectedNodeId, focusNeighbors]);
// With neighbors shown and a node selected, fade every other node (like live mode) to spotlight
// the selection and its neighbors. null when there's nothing to focus, so the map stays full-bright.
const focusIds = useMemo(
() => (neighborLines === "off" ? null : neighborFocusIds(nodes, selectedNodeId)),
[nodes, neighborLines, selectedNodeId],
);
// IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no
// bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing).
const fitPoints = useMemo<[number, number][] | null>(() => {
const withCoords = (iatas ?? []).filter((i) => i.lat != null && i.lon != null);
if (withCoords.length === 0) return null;
const scope = selectedIatas && selectedIatas.length > 0 ? new Set(selectedIatas) : null;
const chosen = scope ? withCoords.filter((i) => scope.has(i.iata)) : withCoords;
return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null;
}, [iatas, selectedIatas]);
// Borders to draw: the selected region's IATAs, or every IATA for "All" (most have none configured,
// which resolves to a 204 and is dropped). Only fetched while the layer is toggled on.
const borderIatas = useMemo(() => {
const all = (iatas ?? []).map((i) => i.iata);
return selectedIatas && selectedIatas.length > 0 ? all.filter((c) => selectedIatas.includes(c)) : all;
}, [iatas, selectedIatas]);
const borderData = useMapBordersData(borderIatas, borders);
const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera);
const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome
// Snapshot the current view (live camera + settings) into deep-link params for the copy button.
// Evaluated at click, so it reads the real camera; forces tab=Map so the link lands on the map.
const buildShareParams = useCallback((): Record<string, string | null> => {
const map = mapRef.current;
const center = map?.getCenter();
const snapshot: MapViewSnapshot = {
center: center ? [center.lng, center.lat] : DEFAULT_CENTER,
zoom: map?.getZoom() ?? DEFAULT_ZOOM,
clustered,
nodeType: typeFilter,
neighborLines,
styleId,
flow: packetFlow,
borders,
};
return { tab: "Map", ...buildMapParams(snapshot) };
}, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]);
useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`);
useMapNeighbors(mapRef, isReady, neighborEdges, themeKey);
useMapBorders(mapRef, isReady, borderData, themeKey);
useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey);
return (
<div className="relative flex flex-1 min-h-0">
{/* Fill via flex-1, NOT absolute inset-0: maplibre adds .maplibregl-map { position: relative }
to this element, which overrides Tailwind's `absolute` and would collapse inset-0 to 0
height. data-dark drives the maplibre control theming in index.css. */}
<div ref={containerRef} data-dark={isDark} className="flex-1" />
<MapSettingsPanel
styleId={styleId}
onStyleChange={handleStyleChange}
typeFilter={typeFilter}
onTypeChange={handleTypeChange}
clustered={clustered}
onClusteredChange={handleClusteredChange}
neighborLines={neighborLines}
onNeighborLinesChange={handleNeighborLinesChange}
borders={borders}
onBordersChange={handleBordersChange}
buildShareParams={buildShareParams}
/>
<PacketFlowButton active={packetFlow} onToggle={() => setPacketFlow((v) => !v)} />
{/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */}
<LoadingPill loading={isPaging} error={nodesError} count={loadedCount} noun="nodes" />
{error && (
// z-20 so the failure overlay covers the settings card (z-10) instead of it floating on top
<div className="absolute inset-0 z-20 bg-bg-base">
<EmptyState title="Map failed to load" subtitle="Check your connection and reload" />
</div>
)}
</div>
);
}