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.
This commit is contained in:
MrAlders0n
2026-07-24 22:21:12 -04:00
parent 8baa562794
commit 464e701f76
10 changed files with 305 additions and 4 deletions
+14
View File
@@ -17,6 +17,9 @@ import type {
NodeTypeCount,
ClockDriftEntry,
} from "../features/stats/types";
import type { Feature, Polygon, MultiPolygon } from "geojson";
export type IataBorder = Feature<Polygon | MultiPolygon>;
// typed fetch wrapper with query params
@@ -81,6 +84,17 @@ export function getIatas(): Promise<IataCode[]> {
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<IataBorder | null> {
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<RegionSummary[]> {
return request("/regions");
}
+31
View File
@@ -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 (
<div className="mt-2.5 flex items-center gap-1.5 text-[10px] text-text-dim">
<span
className="inline-block h-2.5 w-4 rounded-sm border"
style={{ borderColor: "var(--palette-secondary)", backgroundColor: "var(--palette-secondary)", opacity: 0.5 }}
/>
IATA region outline
</div>
);
}
// 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<string, string | null>;
}
@@ -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" && <NeighborLegend />}
</Section>
<Section title="IATA Borders">
<SegmentedControl
ariaLabel="IATA borders"
options={BORDER_OPTIONS}
value={borders ? "on" : "off"}
onChange={(v) => onBordersChange(v === "on")}
className="w-full"
/>
{borders && <BorderLegend />}
</Section>
<div className="px-3 py-2.5 border-t border-border-subtle flex justify-end">
<CopyLinkButton
params={buildShareParams}
+23 -2
View File
@@ -5,13 +5,15 @@ 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, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types";
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";
@@ -87,6 +89,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
// 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),
@@ -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}
/>
<PacketFlowButton active={packetFlow} onToggle={() => setPacketFlow((v) => !v)} />
+6
View File
@@ -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<string, string | n
neighbor_lines: view.neighborLines,
style: view.styleId,
flow: view.flow ? "on" : "off",
borders: view.borders ? "on" : "off",
};
}
+6
View File
@@ -98,6 +98,12 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben
export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines";
export type NeighborLinesMode = "on" | "selected" | "off";
// --- IATA border layer ---
export const IATA_BORDERS_SOURCE_ID = "iata-borders";
export const IATA_BORDERS_FILL_LAYER_ID = "iata-borders-fill"; // low-alpha fill beneath the markers
export const IATA_BORDERS_LINE_LAYER_ID = "iata-borders-line"; // outline stroke over the fill
export const MAP_BORDERS_STORAGE_KEY = "beacon-map-borders";
// --- Live packet-flow (modelled on MeshMapper's "LiveViz"): dim every node, then per packet shoot an
// orange dot along its real hop path with a fading dashed trail, flashing each node as the dot crosses ---
export const PACKET_FLOW_TRAIL_SOURCE_ID = "packet-flow-trail";
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from "react";
import type { Map as MapLibreMap, GeoJSONSource, FillLayerSpecification, LineLayerSpecification } from "maplibre-gl";
import {
IATA_BORDERS_SOURCE_ID,
IATA_BORDERS_FILL_LAYER_ID,
IATA_BORDERS_LINE_LAYER_ID,
NODES_CLUSTER_LAYER_ID,
} from "./types";
import type { BorderFeatureCollection } from "./useMapBordersData";
function paletteVar(name: string, fallback: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
// Draws IATA region borders as a low-alpha fill + outline beneath the node markers. Mirrors
// useMapNeighbors: the source/layers re-add themselves after a style switch, the paint tracks the
// palette on theme change, and border data flows through a separate setData effect so toggling the
// layer on/off never rebuilds it.
export function useMapBorders(
mapRef: React.RefObject<MapLibreMap | null>,
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]);
}
+40
View File
@@ -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<Polygon | MultiPolygon, BorderProps>;
// 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<Polygon | MultiPolygon, BorderProps>]
: [],
);
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],
);
}
+51 -1
View File
@@ -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<Polygon> = {
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);
});
});
+11 -1
View File
@@ -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,
});
});
@@ -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<Polygon> => ({
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);
});
});