mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 00:58:21 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)];
|
||||
|
||||
Reference in New Issue
Block a user