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.
This commit is contained in:
MrAlders0n
2026-07-28 21:27:15 -04:00
parent 4f0ad44d25
commit b74dcdb0cf
4 changed files with 47 additions and 20 deletions
+17 -2
View File
@@ -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.
+3 -13
View File
@@ -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));
}
}
+6 -4
View File
@@ -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({
+21 -1
View File
@@ -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)];