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 = { 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(() => { 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>>(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 => { 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 (
{/* 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. */}
setPacketFlow((v) => !v)} /> {/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */} {error && ( // z-20 so the failure overlay covers the settings card (z-10) instead of it floating on top
)}
); }