diff --git a/src/api/client.ts b/src/api/client.ts index 2518b54..3e29476 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -17,6 +17,9 @@ import type { NodeTypeCount, ClockDriftEntry, } from "../features/stats/types"; +import type { Feature, Polygon, MultiPolygon } from "geojson"; + +export type IataBorder = Feature; // typed fetch wrapper with query params @@ -81,6 +84,17 @@ export function getIatas(): Promise { return request("/iatas"); } +// An IATA's GeoJSON border, or null when none is configured. Can't use request(): the endpoint +// answers 204 (empty body) or a literal `null` for "no border", and request() always parses JSON. +export async function getIataBorder(iata: string): Promise { + const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin); + const res = await fetch(url.toString()); + if (res.status === 204) return null; + if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText); + const body = await res.json(); + return (body ?? null) as IataBorder | null; +} + export function getRegions(): Promise { return request("/regions"); } diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index c8d208a..8f86744 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -19,6 +19,23 @@ const NEIGHBOR_OPTIONS = [ { value: "selected", label: "Selected" }, { value: "off", label: "Off" }, ]; +const BORDER_OPTIONS = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, +]; + +// Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme. +function BorderLegend() { + return ( +
+ + IATA region outline +
+ ); +} // Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors // (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. @@ -49,6 +66,8 @@ interface MapSettingsPanelProps { onClusteredChange: (c: boolean) => void; neighborLines: NeighborLinesMode; onNeighborLinesChange: (mode: NeighborLinesMode) => void; + borders: boolean; + onBordersChange: (on: boolean) => void; // builds deep-link params for the current view, evaluated at copy time (reads the live camera) buildShareParams: () => Record; } @@ -62,6 +81,8 @@ export function MapSettingsPanel({ onClusteredChange, neighborLines, onNeighborLinesChange, + borders, + onBordersChange, buildShareParams, }: MapSettingsPanelProps) { const isMobile = useIsMobile(); @@ -134,6 +155,16 @@ export function MapSettingsPanel({ /> {neighborLines === "selected" && } +
+ onBordersChange(v === "on")} + className="w-full" + /> + {borders && } +
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), @@ -167,6 +176,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp 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 @@ -183,12 +200,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp neighborLines, styleId, flow: packetFlow, + borders, }; return { tab: "Map", ...buildMapParams(snapshot) }; - }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow]); + }, [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 ( @@ -206,6 +225,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp onClusteredChange={handleClusteredChange} neighborLines={neighborLines} onNeighborLinesChange={handleNeighborLinesChange} + borders={borders} + onBordersChange={handleBordersChange} buildShareParams={buildShareParams} /> setPacketFlow((v) => !v)} /> diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index d294318..db55f5e 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -13,6 +13,7 @@ export interface ParsedMapView { neighborLines?: NeighborLinesMode; styleId?: string; flow?: boolean; + borders?: boolean; } // The live map state a copy-link snapshot is built from (every field concrete). @@ -24,6 +25,7 @@ export interface MapViewSnapshot { neighborLines: NeighborLinesMode; styleId: string; flow: boolean; + borders: boolean; } const NEIGHBOR_MODES: NeighborLinesMode[] = ["on", "selected", "off"]; @@ -78,6 +80,9 @@ export function parseMapView(params: URLSearchParams): ParsedMapView { const flow = parseBool(params.get("flow")); if (flow !== undefined) view.flow = flow; + const borders = parseBool(params.get("borders")); + if (borders !== undefined) view.borders = borders; + return view; } @@ -100,5 +105,6 @@ export function buildMapParams(view: MapViewSnapshot): Record, + isReady: boolean, + data: BorderFeatureCollection, + themeKey: string, +) { + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // build source + fill + line, and keep the colour in step with the palette + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const color = paletteVar("--palette-secondary", "#A78BFA"); + const beforeId = map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined; + + if (!map.getSource(IATA_BORDERS_SOURCE_ID)) { + map.addSource(IATA_BORDERS_SOURCE_ID, { type: "geojson", data: dataRef.current }); + } + // fill first so the outline sits on top of it; both go beneath the node markers + if (!map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_FILL_LAYER_ID, + type: "fill", + source: IATA_BORDERS_SOURCE_ID, + paint: { "fill-color": color, "fill-opacity": 0.08 }, + } as FillLayerSpecification, + beforeId, + ); + } + if (!map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_LINE_LAYER_ID, + type: "line", + source: IATA_BORDERS_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": color, "line-width": 1.5, "line-opacity": 0.8 }, + } as LineLayerSpecification, + beforeId, + ); + } + map.setPaintProperty(IATA_BORDERS_FILL_LAYER_ID, "fill-color", color); + map.setPaintProperty(IATA_BORDERS_LINE_LAYER_ID, "line-color", color); + (map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource).setData(dataRef.current); + }, [mapRef, isReady, themeKey]); + + // push new border data as the toggle / region changes + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const src = map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(data); + }, [mapRef, isReady, data]); + + // remove layers (before the source) on unmount; runs before useMapLibre tears the map down + useEffect(() => { + const map = mapRef.current; + return () => { + if (!map) return; + try { + if (map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) map.removeLayer(IATA_BORDERS_LINE_LAYER_ID); + if (map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) map.removeLayer(IATA_BORDERS_FILL_LAYER_ID); + if (map.getSource(IATA_BORDERS_SOURCE_ID)) map.removeSource(IATA_BORDERS_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts new file mode 100644 index 0000000..f89f326 --- /dev/null +++ b/src/features/map/useMapBordersData.ts @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import type { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson"; +import { getIataBorder, type IataBorder } from "../../api/client"; + +export type BorderProps = { iata: string; [key: string]: unknown }; +export type BorderFeatureCollection = FeatureCollection; + +// Merge each IATA's border into one collection, dropping the ones with no border and stamping the +// IATA code onto every feature so the layer can style/label per region. +export function mergeBorders(entries: { iata: string; border: IataBorder | null }[]): BorderFeatureCollection { + const features = entries.flatMap((e) => + e.border + ? [{ ...e.border, properties: { ...(e.border.properties ?? {}), iata: e.iata } } as Feature] + : [], + ); + return { type: "FeatureCollection", features }; +} + +// Fetch the border for each active IATA (only while `enabled`), then merge into one collection. +// Borders are static, so each is cached indefinitely and most IATAs simply have none (204 -> null). +export function useMapBordersData(iataCodes: string[], enabled: boolean): BorderFeatureCollection { + const results = useQueries({ + queries: iataCodes.map((iata) => ({ + queryKey: ["iata-border", iata], + queryFn: () => getIataBorder(iata), + enabled, + staleTime: Infinity, + })), + }); + + // useQueries returns a fresh array each render; a border is immutable once fetched, so a signature + // of which IATAs have resolved one is enough to keep the collection reference stable between renders. + const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}`).join("|"); + return useMemo( + () => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))), + // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded + [sig], + ); +} diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index fa0a860..207a36d 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder } from "../../src/api/client"; +import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -436,3 +437,52 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("100"); }); }); + +describe("getIataBorder", () => { + // this endpoint can 204 (empty body) or send a literal `null`, so mock the status explicitly + function mockStatus(status: number, body: unknown): () => string { + let calledUrl = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calledUrl = url; + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (status === 204) throw new Error("no body to parse"); + return body; + }, + } as Response; + }), + ); + return () => calledUrl; + } + + const feature: Feature = { + type: "Feature", + properties: {}, + geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, + }; + + it("requests /iatas/{iata}/border", async () => { + const getUrl = mockStatus(200, feature); + await getIataBorder("YOW"); + expect(new URL(getUrl()).pathname).toContain("/iatas/YOW/border"); + }); + + it("returns null for a 204 (no border configured) without parsing a body", async () => { + mockStatus(204, undefined); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("treats a literal null body as no border", async () => { + mockStatus(200, null); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("returns the GeoJSON Feature when a border exists", async () => { + mockStatus(200, feature); + await expect(getIataBorder("YOW")).resolves.toEqual(feature); + }); +}); diff --git a/tests/features/map/map-url.test.ts b/tests/features/map/map-url.test.ts index 877819a..485bca2 100644 --- a/tests/features/map/map-url.test.ts +++ b/tests/features/map/map-url.test.ts @@ -70,9 +70,15 @@ describe("parseMapView", () => { expect(parseMapView(new URLSearchParams("flow=x"))).toEqual({}); }); + it("reads the iata-borders toggle on/off", () => { + expect(parseMapView(new URLSearchParams("borders=on"))).toEqual({ borders: true }); + expect(parseMapView(new URLSearchParams("borders=off"))).toEqual({ borders: false }); + expect(parseMapView(new URLSearchParams("borders=x"))).toEqual({}); + }); + it("combines every param into one view", () => { const params = new URLSearchParams( - "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on", + "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on&borders=on", ); expect(parseMapView(params)).toEqual({ center: [-113.58, 53.31], @@ -82,6 +88,7 @@ describe("parseMapView", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); }); @@ -95,6 +102,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }; it("emits every managed key with rounded camera values", () => { @@ -107,6 +115,7 @@ describe("buildMapParams", () => { neighbor_lines: "on", style: "liberty", flow: "on", + borders: "on", }); }); @@ -124,6 +133,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); diff --git a/tests/features/map/useMapBordersData.test.ts b/tests/features/map/useMapBordersData.test.ts new file mode 100644 index 0000000..b7ac299 --- /dev/null +++ b/tests/features/map/useMapBordersData.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { mergeBorders } from "../../../src/features/map/useMapBordersData"; +import type { Feature, Polygon } from "geojson"; + +const poly = (id: number): Feature => ({ + type: "Feature", + properties: { name: `p${id}` }, + geometry: { type: "Polygon", coordinates: [[[id, 0], [id + 1, 0], [id + 1, 1], [id, 0]]] }, +}); + +describe("mergeBorders", () => { + it("drops IATAs with no border and stamps the iata onto each feature's properties", () => { + const fc = mergeBorders([ + { iata: "YOW", border: poly(0) }, + { iata: "YYZ", border: null }, + { iata: "YUL", border: poly(5) }, + ]); + + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features).toHaveLength(2); + expect(fc.features.map((f) => f.properties.iata)).toEqual(["YOW", "YUL"]); + // existing properties and geometry survive the merge + expect(fc.features[0]!.properties.name).toBe("p0"); + expect(fc.features[0]!.geometry).toEqual(poly(0).geometry); + }); + + it("returns an empty FeatureCollection when nothing has a border", () => { + const fc = mergeBorders([{ iata: "YOW", border: null }]); + expect(fc.features).toHaveLength(0); + }); +});