mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 22:21:24 +00:00
Add path-map deep links, per-observer propagation, and tappable nodes
This commit is contained in:
1 parent
2132e78f28
commit
02ec63cc0f
7 files changed
+163
-54
No files matched your search
+21
-3
@@ -141,6 +141,9 @@ function AppInner() {
|
||||
const [overlayPacketHash, setOverlayPacketHash] = useState<string | null>(null);
|
||||
// packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map")
|
||||
const [pathMapDetail, setPathMapDetail] = useState<PacketDetail | null>(null);
|
||||
const [initialPath] = useState<string | null>(() => searchParams.get("path"));
|
||||
const [pathMapInitialKey, setPathMapInitialKey] = useState<string | null>(null);
|
||||
const pathLinkHandledRef = useRef(false);
|
||||
|
||||
// short staleTime: observations keep accruing, so reopening the analyzer should show them
|
||||
// instead of a snapshot frozen at first open
|
||||
@@ -158,6 +161,14 @@ function AppInner() {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// deep link: ?hash opens the analyzer drawer; ?path then opens the path popup once, pre-selected
|
||||
useEffect(() => {
|
||||
if (!initialPath || !analyzerDetail || pathLinkHandledRef.current) return;
|
||||
pathLinkHandledRef.current = true;
|
||||
setPathMapDetail(analyzerDetail);
|
||||
setPathMapInitialKey(initialPath);
|
||||
}, [initialPath, analyzerDetail]);
|
||||
|
||||
const handleAnalyze = useCallback((hash: string | null) => {
|
||||
setAnalyzerHash(hash);
|
||||
setSelectedObservationId(null);
|
||||
@@ -274,7 +285,7 @@ function AppInner() {
|
||||
onSelectObservation={setSelectedObservationId}
|
||||
onClose={() => handleAnalyze(null)}
|
||||
onViewNode={setOverlayNodeId}
|
||||
onViewPath={() => { if (analyzerDetail) setPathMapDetail(analyzerDetail); }}
|
||||
onViewPath={() => { if (analyzerDetail) { setPathMapDetail(analyzerDetail); setPathMapInitialKey(null); } }}
|
||||
/>
|
||||
)}
|
||||
{(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && (
|
||||
@@ -309,12 +320,19 @@ function AppInner() {
|
||||
handleTabChange("Observers");
|
||||
setSelectedObserverId(observerId);
|
||||
}}
|
||||
onViewPath={() => { if (overlayPacketDetail) setPathMapDetail(overlayPacketDetail); }}
|
||||
onViewPath={() => { if (overlayPacketDetail) { setPathMapDetail(overlayPacketDetail); setPathMapInitialKey(null); } }}
|
||||
inactive={!!pathMapDetail}
|
||||
/>
|
||||
)}
|
||||
{pathMapDetail && (
|
||||
<PacketPathMapModal detail={pathMapDetail} onClose={() => setPathMapDetail(null)} />
|
||||
<PacketPathMapModal
|
||||
detail={pathMapDetail}
|
||||
initialSelectedKey={pathMapInitialKey}
|
||||
onClose={() => {
|
||||
setPathMapDetail(null);
|
||||
setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CircleLayerSpecification,
|
||||
SymbolLayerSpecification,
|
||||
} from "maplibre-gl";
|
||||
import type { Point } from "geojson";
|
||||
import type { PacketPath } from "./packet-path";
|
||||
import { packetPathsToFeatures } from "./packet-path";
|
||||
import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types";
|
||||
@@ -52,6 +53,25 @@ export function PacketPathMap({ paths, selectedKey, styleId }: {
|
||||
const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib");
|
||||
attrib?.classList.add("maplibregl-compact");
|
||||
attrib?.classList.remove("maplibregl-compact-show");
|
||||
// click a path node → popup with its name and raw-decimal coords; pointer cursor on hover
|
||||
map.on("mouseenter", NODE_LAYER, () => { map.getCanvas().style.cursor = "pointer"; });
|
||||
map.on("mouseleave", NODE_LAYER, () => { map.getCanvas().style.cursor = ""; });
|
||||
map.on("click", NODE_LAYER, (e) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const [lng, lat] = (f.geometry as Point).coordinates as [number, number];
|
||||
const el = document.createElement("div");
|
||||
const name = document.createElement("div");
|
||||
name.className = "pp-popup-name";
|
||||
name.textContent = (f.properties?.title as string) ?? "";
|
||||
const coords = document.createElement("div");
|
||||
coords.className = "pp-popup-coords";
|
||||
coords.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
||||
el.append(name, coords);
|
||||
// a fresh popup per click; closeOnClick removes the previous one on the same click
|
||||
new maplibregl.Popup({ closeButton: false, closeOnClick: true, offset: 10 })
|
||||
.setLngLat([lng, lat]).setDOMContent(el).addTo(map);
|
||||
});
|
||||
const onLoad = () => setReady(true);
|
||||
map.on("load", onLoad);
|
||||
return () => {
|
||||
@@ -109,5 +129,5 @@ export function PacketPathMap({ paths, selectedKey, styleId }: {
|
||||
}
|
||||
}, [ready, paths, selectedKey]);
|
||||
|
||||
return <div ref={containerRef} className="w-full h-full" />;
|
||||
return <div ref={containerRef} data-dark={resolveMapStyle(styleId).dark} className="w-full h-full" />;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import type { PacketDetail } from "../../types/api";
|
||||
import { ModalOverlay } from "../../components/ModalOverlay";
|
||||
import { CloseButton } from "../../components/CloseButton";
|
||||
import { CopyLinkButton } from "../../components/CopyLinkButton";
|
||||
import { formatPropagation } from "../../lib/formatters";
|
||||
import { buildPacketPaths } from "./packet-path";
|
||||
import { PacketPathMap } from "./PacketPathMap";
|
||||
import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types";
|
||||
@@ -9,8 +11,8 @@ import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types";
|
||||
// Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route;
|
||||
// clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it
|
||||
// returns the user exactly where they were.
|
||||
function Row({ active, color, label, hops, onClick }: {
|
||||
active: boolean; color?: string; label: string; hops?: number; onClick: () => void;
|
||||
function Row({ active, color, label, meta, onClick }: {
|
||||
active: boolean; color?: string; label: string; meta?: string; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -22,14 +24,21 @@ function Row({ active, color, label, hops, onClick }: {
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={color ? { backgroundColor: color } : undefined} />
|
||||
<span className="truncate">{label}</span>
|
||||
{hops != null && <span className="ml-auto text-text-dim">{hops}h</span>}
|
||||
{meta != null && <span className="ml-auto text-text-dim">{meta}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail; onClose: () => void }) {
|
||||
export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: {
|
||||
detail: PacketDetail;
|
||||
onClose: () => void;
|
||||
initialSelectedKey?: string | null;
|
||||
}) {
|
||||
const paths = useMemo(() => buildPacketPaths(detail), [detail]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null); // null = All paths
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(
|
||||
// deep-link value that matches a known path isolates it; anything else (incl. "all") shows All
|
||||
() => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null),
|
||||
);
|
||||
const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +54,13 @@ export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail;
|
||||
<div className="h-full w-full md:w-[860px] md:max-w-[92vw] bg-bg-surface flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
|
||||
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Packet Path</span>
|
||||
<CloseButton onClose={onClose} label="Close path map" className="-mr-1" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CopyLinkButton
|
||||
params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })}
|
||||
ariaLabel="Copy path link"
|
||||
/>
|
||||
<CloseButton onClose={onClose} label="Close path map" className="-mr-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
@@ -62,7 +77,7 @@ export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail;
|
||||
active={selectedKey === p.key}
|
||||
color={p.color}
|
||||
label={p.label}
|
||||
hops={p.hopCount}
|
||||
meta={formatPropagation(p.propagationMs)}
|
||||
onClick={() => setSelectedKey(p.key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,9 +10,9 @@ export interface PathPoint {
|
||||
}
|
||||
|
||||
export interface PacketPath {
|
||||
key: string; // observation id as string, or "trace"
|
||||
label: string; // observer name, or a truncated observer id, or "Trace route"
|
||||
hopCount: number;
|
||||
key: string; // observerId, or "trace"
|
||||
label: string; // observer name, a truncated observer id, or "Trace route"
|
||||
propagationMs?: number; // packet's propagation to this observer (ms); absent for the trace route
|
||||
color: string;
|
||||
points: PathPoint[];
|
||||
}
|
||||
@@ -50,22 +50,25 @@ function observerLabel(obs: Observation): string {
|
||||
}
|
||||
|
||||
// One drawable path per observation (and the trace route for TRACE packets) that resolves to >=2
|
||||
// located hops. Colors are assigned by result index so they stay contiguous after filtering.
|
||||
// located hops, keyed by observerId and sorted fastest-first. Colors are assigned after sorting so
|
||||
// the selector swatch matches the drawn line.
|
||||
export function buildPacketPaths(detail: PacketDetail): PacketPath[] {
|
||||
const paths: PacketPath[] = [];
|
||||
const push = (key: string, label: string, hopCount: number, points: PathPoint[]) => {
|
||||
const raw: Omit<PacketPath, "color">[] = [];
|
||||
const add = (key: string, label: string, propagationMs: number | undefined, points: PathPoint[]) => {
|
||||
if (points.length < 2) return;
|
||||
paths.push({ key, label, hopCount, color: PATH_COLORS[paths.length % PATH_COLORS.length]!, points });
|
||||
raw.push({ key, label, propagationMs, points });
|
||||
};
|
||||
|
||||
for (const obs of detail.observations) {
|
||||
push(String(obs.id), observerLabel(obs), obs.pathLength.hopCount, pathPoints(obs.resolvedPath));
|
||||
add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(obs.resolvedPath));
|
||||
}
|
||||
if (detail.header.payloadType === PayloadType.TRACE && detail.resolvedRoute) {
|
||||
const pts = pathPoints(detail.resolvedRoute);
|
||||
push("trace", "Trace route", detail.resolvedRoute.length, pts);
|
||||
add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute));
|
||||
}
|
||||
return paths;
|
||||
|
||||
// fastest first; missing propagation (incl. the trace route) sorts last
|
||||
raw.sort((a, b) => ((a.propagationMs ?? Infinity) - (b.propagationMs ?? Infinity)) || 0); // || 0: two Infinity props → NaN; keep insertion order
|
||||
return raw.map((p, i) => ({ ...p, color: PATH_COLORS[i % PATH_COLORS.length]! }));
|
||||
}
|
||||
|
||||
export interface PathLineProps {
|
||||
@@ -76,7 +79,8 @@ export interface PathLineProps {
|
||||
export interface PathNodeProps {
|
||||
key: string;
|
||||
color: string;
|
||||
label: string;
|
||||
label: string; // short label for the map (truncated id when unnamed)
|
||||
title: string; // untruncated name/id for the click popup
|
||||
endpoint: "start" | "end" | "mid";
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ export function packetPathsToFeatures(
|
||||
const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid";
|
||||
points.push({
|
||||
type: "Feature",
|
||||
properties: { key: path.key, color: path.color, label: pt.name ?? pt.id.slice(0, 6), endpoint },
|
||||
properties: { key: path.key, color: path.color, label: pt.name ?? pt.id.slice(0, 6), title: pt.name ?? pt.id, endpoint },
|
||||
geometry: { type: "Point", coordinates: [pt.lng, pt.lat] },
|
||||
});
|
||||
bounds.push([pt.lng, pt.lat]);
|
||||
|
||||
+18
-1
@@ -107,7 +107,7 @@
|
||||
}
|
||||
|
||||
/* MapLibre controls ship light chrome; re-skin to the app's dark tokens when the active basemap
|
||||
is dark (data-dark set on the map container in MapView). Glyphs are baked-in dark SVGs, so they
|
||||
is dark (data-dark set on each map container — MapView and the packet-path mini-map). Glyphs are baked-in dark SVGs, so they
|
||||
are inverted rather than recolored. Light basemaps (Liberty/Light) keep the default chrome. */
|
||||
[data-dark="true"] .maplibregl-ctrl-group {
|
||||
background: var(--color-bg-raised);
|
||||
@@ -134,3 +134,20 @@
|
||||
[data-dark="true"] .maplibregl-ctrl-attrib a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
/* Path-node popup: dark chrome to match the controls, plus name/coords typography. */
|
||||
[data-dark="true"] .maplibregl-popup-content {
|
||||
background: var(--color-bg-raised);
|
||||
color: var(--color-text-normal);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-dark="true"] .maplibregl-popup-anchor-top .maplibregl-popup-tip,
|
||||
[data-dark="true"] .maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
|
||||
[data-dark="true"] .maplibregl-popup-anchor-top-right .maplibregl-popup-tip { border-bottom-color: var(--color-bg-raised); }
|
||||
[data-dark="true"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
|
||||
[data-dark="true"] .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
|
||||
[data-dark="true"] .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { border-top-color: var(--color-bg-raised); }
|
||||
[data-dark="true"] .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--color-bg-raised); }
|
||||
[data-dark="true"] .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--color-bg-raised); }
|
||||
.pp-popup-name { font-weight: 600; }
|
||||
.pp-popup-coords { font-family: ui-monospace, monospace; font-size: 11px; color: var(--color-text-muted); margin-top: 2px; }
|
||||
@@ -27,8 +27,8 @@ const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as c
|
||||
const detail = {
|
||||
header: { payloadType: PayloadType.TEXT, routeType: 1 },
|
||||
observations: [
|
||||
{ id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] },
|
||||
{ id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)] },
|
||||
{ id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], propagationTimeMs: 100 },
|
||||
{ id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)], propagationTimeMs: 480 },
|
||||
],
|
||||
} as unknown as PacketDetail;
|
||||
|
||||
@@ -44,7 +44,13 @@ describe("PacketPathMapModal", () => {
|
||||
it("isolates a path when its row is clicked", () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByText("Bravo"));
|
||||
expect(screen.getByTestId("mini-map")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo");
|
||||
});
|
||||
|
||||
it("shows each observer's propagation", () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} />);
|
||||
expect(screen.getByText("0.100s")).toBeInTheDocument(); // formatPropagation(100)
|
||||
expect(screen.getByText("0.480s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes from the close button", () => {
|
||||
@@ -53,4 +59,19 @@ describe("PacketPathMapModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Close path map"));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("pre-selects the observer from initialSelectedKey", () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} initialSelectedKey="obs-bravo" />);
|
||||
expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo");
|
||||
});
|
||||
|
||||
it("falls back to All when initialSelectedKey isn't a known path", () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} initialSelectedKey="nope" />);
|
||||
expect(screen.getByTestId("mini-map")).toHaveTextContent("all");
|
||||
});
|
||||
|
||||
it("renders a copy-link button", () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "Copy path link" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -21,49 +21,52 @@ function detail(observations: Observation[], over: Partial<PacketDetail> = {}):
|
||||
}
|
||||
|
||||
describe("buildPacketPaths", () => {
|
||||
it("returns one color-coded path per observation with >=2 located hops", () => {
|
||||
it("keys each path by observerId and carries propagation, fastest first", () => {
|
||||
const d = detail([
|
||||
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerName: "Alpha" }),
|
||||
obs(2, [hop("c", -80, 44), hop("d", -76, 46)]),
|
||||
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }),
|
||||
obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", observerName: "Fast", propagationTimeMs: 100 }),
|
||||
]);
|
||||
const paths = buildPacketPaths(d);
|
||||
expect(paths).toHaveLength(2);
|
||||
expect(paths[0]).toMatchObject({ key: "1", label: "Alpha", hopCount: 2, color: PATH_COLORS[0] });
|
||||
expect(paths[1]).toMatchObject({ key: "2", label: "observer", color: PATH_COLORS[1] });
|
||||
expect(paths[0]!.points).toEqual([
|
||||
{ id: "a", name: undefined, lng: -79, lat: 43 },
|
||||
{ id: "b", name: undefined, lng: -75, lat: 45 },
|
||||
]);
|
||||
expect(paths.map((p) => p.key)).toEqual(["obs-fast", "obs-slow"]); // fastest first
|
||||
expect(paths[0]).toMatchObject({ key: "obs-fast", label: "Fast", propagationMs: 100, color: PATH_COLORS[0] });
|
||||
expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order
|
||||
});
|
||||
|
||||
it("sorts missing propagation and the trace route last", () => {
|
||||
const d = detail(
|
||||
[
|
||||
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation
|
||||
obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }),
|
||||
],
|
||||
{
|
||||
header: { payloadType: PayloadType.TRACE, routeType: 1 },
|
||||
resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)],
|
||||
} as unknown as Partial<PacketDetail>,
|
||||
);
|
||||
const keys = buildPacketPaths(d).map((p) => p.key);
|
||||
expect(keys[0]).toBe("obs-fast");
|
||||
expect(keys).toContain("obs-none");
|
||||
expect(keys[keys.length - 1]).toBe("trace"); // trace (no propagation) last
|
||||
});
|
||||
|
||||
it("omits observations that resolve to fewer than 2 located hops", () => {
|
||||
const d = detail([
|
||||
obs(1, [hop("a", -79, 43), hop("x")]), // one unlocated -> 1 point -> omitted
|
||||
obs(2, [hop("b", -80, 44), hop("c", -76, 46)]),
|
||||
obs(1, [hop("a", -79, 43), hop("x")], { observerId: "obs-1" }),
|
||||
obs(2, [hop("b", -80, 44), hop("c", -76, 46)], { observerId: "obs-2" }),
|
||||
]);
|
||||
expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("includes the trace route for TRACE packets", () => {
|
||||
const d = detail([], {
|
||||
header: { payloadType: PayloadType.TRACE, routeType: 1 },
|
||||
resolvedRoute: [hop("a", -79, 43), hop("b", -75, 45)],
|
||||
} as unknown as Partial<PacketDetail>);
|
||||
const paths = buildPacketPaths(d);
|
||||
expect(paths).toHaveLength(1);
|
||||
expect(paths[0]).toMatchObject({ key: "trace", label: "Trace route", hopCount: 2 });
|
||||
expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["obs-2"]);
|
||||
});
|
||||
|
||||
it("returns empty when nothing is drawable", () => {
|
||||
expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)])]))).toEqual([]);
|
||||
expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)], { observerId: "obs-1" })]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const P: PacketPath[] = [
|
||||
{ key: "1", label: "A", hopCount: 3, color: "#111", points: [
|
||||
{ key: "1", label: "A", color: "#111", points: [
|
||||
{ id: "a", lng: -79, lat: 43 }, { id: "b", lng: -78, lat: 44 }, { id: "c", lng: -77, lat: 45 },
|
||||
] },
|
||||
{ key: "2", label: "B", hopCount: 2, color: "#222", points: [
|
||||
{ key: "2", label: "B", color: "#222", points: [
|
||||
{ id: "d", lng: -80, lat: 46 }, { id: "e", lng: -76, lat: 47 },
|
||||
] },
|
||||
];
|
||||
@@ -89,4 +92,15 @@ describe("packetPathsToFeatures", () => {
|
||||
expect(lines.features.map((f) => f.properties.key)).toEqual(["2"]);
|
||||
expect(points.features).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("carries the untruncated node identity as title", () => {
|
||||
const path: PacketPath = { key: "k", label: "L", color: "#111", points: [
|
||||
{ id: "abcdef123456", lng: -79, lat: 43 }, // no name -> title is the full id
|
||||
{ id: "z", name: "Repeater North", lng: -78, lat: 44 }, // named -> title is the name
|
||||
] };
|
||||
const { points } = packetPathsToFeatures([path], null);
|
||||
expect(points.features[0]!.properties.title).toBe("abcdef123456");
|
||||
expect(points.features[0]!.properties.label).toBe("abcdef"); // label stays truncated for the map
|
||||
expect(points.features[1]!.properties.title).toBe("Repeater North");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user