From b74dcdb0cf56cff468ffbc232abf32f0ef7710f0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 28 Jul 2026 21:27:15 -0400 Subject: [PATCH] Animate the live map path from source through to destination Share the endpoint-gating chain builder with the path map so both plot source and destination when the backend resolved them unambiguously. --- src/features/map/packet-flow.ts | 19 +++++++++++++++++-- src/features/map/packet-path.ts | 16 +++------------- src/features/map/useMapPacketFlow.ts | 10 ++++++---- tests/features/map/packet-flow.test.ts | 22 +++++++++++++++++++++- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 23208ca..cce4657 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -1,7 +1,22 @@ import type { ResolvedHop } from "../../types/api"; -// Pure helpers for the live packet-flow animation (modelled on MeshMapper's LiveViz). No maplibre -// import, so they stay unit-testable; the hook owns the layers, the rAF loop, and the node flashes. +// Pure helpers for drawing a packet's path — the live flow animation (modelled on MeshMapper's +// LiveViz) and the path map share them. No maplibre import, so they stay unit-testable; the hook +// owns the layers, the rAF loop, and the node flashes. + +// The full chain for one observation: source → relay hops → destination. Both maps plot one marker +// per hop, so an ambiguous endpoint (a 1-byte prefix matching several candidate nodes) would force +// us to guess which node actually sent or received the packet — only plot endpoints the backend +// resolved unambiguously. Relay hops carry no such gate; they fall back to their first located +// candidate. WS types the endpoints nullable where REST leaves them optional, hence both here. +export function packetChain( + source: ResolvedHop | null | undefined, + path: ResolvedHop[], + destination: ResolvedHop | null | undefined, +): ResolvedHop[] { + const confident = (hop: ResolvedHop | null | undefined) => (hop?.confidence === "high" ? hop : undefined); + return [confident(source), ...path, confident(destination)].filter((hop): hop is ResolvedHop => hop != null); +} // The located nodes on a packet's resolved path — first candidate per hop, deduped by id. The dot // rides these coords and flashes each node as it crosses. diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 4c415e9..b0d5553 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -1,6 +1,7 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; import { PayloadType } from "../../types/enums"; +import { packetChain } from "./packet-flow"; export interface PathPoint { id: string; @@ -49,14 +50,6 @@ function observerLabel(obs: Observation): string { return obs.observerName ?? obs.observerId.slice(0, 8); } -// The map plots one marker per hop, so an ambiguous endpoint (a 1-byte prefix matching several -// candidate nodes) would force us to guess which node actually sent/received the packet. Only plot -// source/destination when the backend resolved it unambiguously ("high"); ambiguous/unresolved -// endpoints are left off the line — the analyzer still lists every candidate for them. -function confidentEndpoint(hop: ResolvedHop | undefined): ResolvedHop | undefined { - return hop?.confidence === "high" ? hop : undefined; -} - // One drawable path per observation (and the trace route for TRACE packets) that resolves to >=2 // located hops, keyed by observerId and sorted fastest-first. Colors are assigned after sorting so // the selector swatch matches the drawn line. @@ -72,11 +65,8 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { // lines would just duplicate the single "Trace route" below — draw only that one for traces. if (!isTrace) { for (const obs of detail.observations) { - // full chain: source → relay hops → destination. Endpoints only when unambiguously resolved - // (see confidentEndpoint); missing/unlocated hops drop out in pathPoints. - const chain = [confidentEndpoint(obs.resolvedSource), ...obs.resolvedPath, confidentEndpoint(obs.resolvedDestination)].filter( - (h): h is ResolvedHop => h != null, - ); + // full chain: source → relay hops → destination; missing/unlocated hops drop out in pathPoints. + const chain = packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination); add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); } } diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index b4e815f..ed3ed9a 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification } from "maplibre-gl"; import type { Feature, FeatureCollection, Point, LineString } from "geojson"; import type { WsManager } from "../../api/ws-manager"; -import { resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; +import { packetChain, resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; import { PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_TRAIL_LAYER_ID, @@ -173,9 +173,11 @@ export function useMapPacketFlow( if (!enabled) return; const map = mapRef.current; const unsub = wsManager.onPacketObservation((data) => { - const resolved = data.observation?.resolvedPath; - if (!resolved) return; - const nodes = resolvedPathNodes(resolved); + const obs = data.observation; + // resolvedPath is opt-in and the toggle above lands a beat after connect, but the endpoints + // always ship — bail rather than animate a bare source→destination hop that never happened. + if (!obs?.resolvedPath) return; + const nodes = resolvedPathNodes(packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination)); if (nodes.length < 2) return; // need at least two located hops to animate a path while (flowsRef.current.length >= PACKET_FLOW_MAX) flowsRef.current.shift(); flowsRef.current.push({ diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 05155a7..e390657 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,11 +1,31 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathNodes, posAtHop, trailCoords } from "../../../src/features/map/packet-flow"; +import { packetChain, resolvedPathNodes, posAtHop, trailCoords } from "../../../src/features/map/packet-flow"; import type { ResolvedHop } from "../../../src/types/api"; function hop(id: string, lng: number, lat: number): ResolvedHop { return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }; } +describe("packetChain", () => { + const relay = hop("r", -75, 45); + + it("wraps the relay hops in a high-confidence source and destination", () => { + const src = hop("s", -70, 40); + const dst = hop("d", -80, 50); + expect(packetChain(src, [relay], dst)).toEqual([src, relay, dst]); + }); + + it("drops ambiguous and unresolved endpoints", () => { + const ambiguous: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk", longitude: -70, latitude: 40 }] }; + expect(packetChain(ambiguous, [relay], { confidence: "none", nodes: [] })).toEqual([relay]); + }); + + it("accepts null or absent endpoints and leaves relay hops untouched", () => { + const ambiguousRelay: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "y", publicKey: "pk", longitude: -76, latitude: 46 }] }; + expect(packetChain(null, [relay, ambiguousRelay], undefined)).toEqual([relay, ambiguousRelay]); + }); +}); + describe("resolvedPathNodes", () => { it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("a", -75, 45), hop("b", -76, 46)];