mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-03 20:23:56 +00:00
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.
244 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|