Add path-map deep links, per-observer propagation, and tappable nodes

This commit is contained in:
MrAlders0n committed 2026-07-18 13:37:42 -04:00
1 parent 2132e78f28
commit 02ec63cc0f
7 files changed
+163 -54

No files matched your search

+21 -3
View File
@@ -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>
+21 -1
View File
@@ -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" />;
}
+22 -7
View File
@@ -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)}
/>
))}
+17 -13
View File
@@ -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
View File
@@ -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; }
+24 -3
View File
@@ -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();
});
});
+40 -26
View File
@@ -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");
});
});