mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 09:03:44 +00:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user