diff --git a/src/App.tsx b/src/App.tsx index 65f25b4..463e4d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,6 +141,9 @@ function AppInner() { const [overlayPacketHash, setOverlayPacketHash] = useState(null); // packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map") const [pathMapDetail, setPathMapDetail] = useState(null); + const [initialPath] = useState(() => searchParams.get("path")); + const [pathMapInitialKey, setPathMapInitialKey] = useState(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 && ( - setPathMapDetail(null)} /> + { + setPathMapDetail(null); + setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true }); + }} + /> )} diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 8f347b0..87d6cea 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -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
; + return
; } diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index 751fb35..3209c25 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -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 ( ); } -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(null); // null = All paths + const [selectedKey, setSelectedKey] = useState( + // 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;
Packet Path - +
+ ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })} + ariaLabel="Copy path link" + /> + +
@@ -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)} /> ))} diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 3ce5532..9715fdb 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -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[] = []; + 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]); diff --git a/src/index.css b/src/index.css index f822762..cf8b878 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index 311416f..c626adc 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -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( {}} />); 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( {}} />); + 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( {}} initialSelectedKey="obs-bravo" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + }); + + it("falls back to All when initialSelectedKey isn't a known path", () => { + render( {}} initialSelectedKey="nope" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + }); + + it("renders a copy-link button", () => { + render( {}} />); + expect(screen.getByRole("button", { name: "Copy path link" })).toBeInTheDocument(); + }); }); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index b48fdf3..79514eb 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -21,49 +21,52 @@ function detail(observations: Observation[], over: Partial = {}): } 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, + ); + 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); - 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"); + }); });