map: live mode dims nodes, flashes each packet's route

This commit is contained in:
MrAlders0n
2026-07-06 08:00:33 -04:00
parent e50501879a
commit b5e2e5dcac
4 changed files with 123 additions and 170 deletions
+30 -66
View File
@@ -1,84 +1,48 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ResolvedHop } from "../../types/api";
// Geometry helpers for the packet-flow animation. No maplibre import, so they stay unit-testable.
// Pure helpers for the live packet-flow highlight. No maplibre import, so they stay unit-testable.
// The [lng, lat] path a packet took, one point per resolved hop. Each hop uses its first located
// candidate; hops we can't place are dropped, so the route can come back with fewer than 2 points.
export function resolvedPathToRoute(resolvedPath: ResolvedHop[]): [number, number][] {
const route: [number, number][] = [];
// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. These are
// the nodes that light up when the packet is observed.
export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] {
const seen = new Set<string>();
const out: { id: string; lng: number; lat: number }[] = [];
for (const hop of resolvedPath) {
const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null);
if (node) route.push([node.longitude!, node.latitude!]);
if (node && !seen.has(node.id)) {
seen.add(node.id);
out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! });
}
}
return route;
return out;
}
// Cumulative segment lengths (planar distance in degrees — accurate enough at mesh scale) so a pulse
// can be placed by fraction of total path length rather than fraction of hop count.
export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } {
const cumLengths: number[] = [0];
for (let i = 1; i < coords.length; i++) {
const [x0, y0] = coords[i - 1]!;
const [x1, y1] = coords[i]!;
cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0));
}
return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 };
// A node currently lit because it was on a recently-observed path. litAt is performance.now().
export interface LitNode {
lng: number;
lat: number;
litAt: number;
}
// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length.
export function positionAt(
coords: [number, number][],
cumLengths: number[],
total: number,
t: number,
): [number, number] {
if (coords.length === 1 || total === 0) return coords[0]!;
const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t;
const target = clamped * total;
let i = 1;
while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++;
const segStart = cumLengths[i - 1]!;
const segEnd = cumLengths[i]!;
const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart);
const [x0, y0] = coords[i - 1]!;
const [x1, y1] = coords[i]!;
return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac];
}
// One in-flight packet animation. cumLengths/total are precomputed (routeMetrics) so each frame is
// just an interpolation.
export interface Pulse {
id: number;
coords: [number, number][];
cumLengths: number[];
total: number;
startMs: number;
durationMs: number;
}
export interface PulseFeatureProps {
export interface LitFeatureProps {
opacity: number;
}
// Elapsed fraction of a pulse's life; >1 once it has arrived (the caller expires those).
export function pulseProgress(pulse: Pulse, nowMs: number): number {
return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs;
// Opacity of a lit node: 1 the instant it lights, linearly down to 0 by fadeMs, clamped past that.
export function litOpacity(litAt: number, nowMs: number, fadeMs: number): number {
const t = (nowMs - litAt) / fadeMs;
if (t <= 0) return 1;
if (t >= 1) return 0;
return 1 - t;
}
// Snapshot the live pulses as point features at their current positions. Opacity holds at 1 then
// eases out over the final quarter so a pulse fades as it reaches the last repeater.
export function buildPulseFC(pulses: Pulse[], nowMs: number): FeatureCollection<Point, PulseFeatureProps> {
const features: Feature<Point, PulseFeatureProps>[] = [];
for (const p of pulses) {
const t = pulseProgress(p, nowMs);
const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t;
const opacity = clamped < 0.75 ? 1 : Math.max(0, 1 - (clamped - 0.75) / 0.25);
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, clamped) },
properties: { opacity },
});
}
// Snapshot the currently-lit nodes as point features carrying their faded opacity.
export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): FeatureCollection<Point, LitFeatureProps> {
const features: Feature<Point, LitFeatureProps>[] = litNodes.map((n) => ({
type: "Feature",
geometry: { type: "Point", coordinates: [n.lng, n.lat] },
properties: { opacity: litOpacity(n.litAt, nowMs, fadeMs) },
}));
return { type: "FeatureCollection", features };
}
+4 -5
View File
@@ -96,12 +96,11 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben
export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines";
export type NeighborLinesMode = "on" | "selected" | "off";
// --- Live packet-flow animation ---
// --- Live packet-flow: dim every node, then flash a packet's resolved-path nodes and fade them out ---
export const PACKET_FLOW_SOURCE_ID = "packet-flow";
export const PACKET_FLOW_LAYER_ID = "packet-flow-pulses"; // circle layer on top (moving pulse)
export const PACKET_FLOW_MAX_PULSES = 60; // cap concurrent animations; drop the oldest past this
export const PACKET_FLOW_SEGMENT_MS = 700; // pulse travel time per hop segment
export const PACKET_FLOW_DEDUP_MS = 3000; // collapse repeat observations of one packetHash within this window
export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes
export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this
export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on
export const CLUSTER_RADIUS = 50; // px
// Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops
+56 -59
View File
@@ -1,25 +1,31 @@
import { useCallback, useEffect, useRef } from "react";
import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification } from "maplibre-gl";
import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl";
import type { FeatureCollection } from "geojson";
import type { WsManager } from "../../api/ws-manager";
import { resolvedPathToRoute, routeMetrics, buildPulseFC, pulseProgress, type Pulse } from "./packet-flow";
import { resolvedPathNodes, buildLitFC, type LitNode } from "./packet-flow";
import {
PACKET_FLOW_SOURCE_ID,
PACKET_FLOW_LAYER_ID,
PACKET_FLOW_MAX_PULSES,
PACKET_FLOW_SEGMENT_MS,
PACKET_FLOW_DEDUP_MS,
PACKET_FLOW_FADE_MS,
LIVE_DIM_OPACITY,
NODES_POINT_LAYER_ID,
NODES_CLUSTER_LAYER_ID,
NODE_LABEL_MIN_ZOOM,
} from "./types";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
// node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off
const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1];
function paletteVar(name: string, fallback: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
// Live "packets moving between repeaters" overlay. While enabled it turns on resolvedPath over the
// WS (setResolvePath) and animates a pulse along each observed packet's path. Geometry is pure
// (packet-flow.ts); here we own the maplibre source, the rAF loop, and the subscription.
// Live mode: dim every node/cluster to near-invisible, then flash the nodes on each observed packet's
// resolved path to full opacity and fade them back over PACKET_FLOW_FADE_MS. Enabling it also opts the
// WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the maplibre
// highlight layer, the dimming, the rAF fade loop, and the subscription.
export function useMapPacketFlow(
mapRef: React.RefObject<MapLibreMap | null>,
isReady: boolean,
@@ -28,47 +34,46 @@ export function useMapPacketFlow(
themeKey: string,
resetKey: string,
) {
const pulsesRef = useRef<Pulse[]>([]);
const litRef = useRef<Map<string, LitNode>>(new Map());
const rafRef = useRef<number | null>(null);
const nextIdRef = useRef(0);
const recentRef = useRef<Map<string, number>>(new Map());
// start the rAF loop if it's idle. The frame reschedules itself until the last pulse expires, then
// leaves rafRef null so we stop instead of spinning on an empty source.
// fade loop: recompute each lit node's opacity, drop the fully-faded, stop once none remain
const startLoop = useCallback(() => {
if (rafRef.current != null) return;
function frame() {
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
const now = performance.now();
pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1);
if (src) src.setData(buildPulseFC(pulsesRef.current, now));
rafRef.current = pulsesRef.current.length > 0 ? requestAnimationFrame(frame) : null;
for (const [id, n] of litRef.current) {
if (now - n.litAt >= PACKET_FLOW_FADE_MS) litRef.current.delete(id);
}
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
if (src) src.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS));
rafRef.current = litRef.current.size > 0 ? requestAnimationFrame(frame) : null;
}
rafRef.current = requestAnimationFrame(frame);
}, [mapRef]);
// build the pulse source + layer; re-adds itself after every style switch, re-tints on theme change
// build the highlight source + layer (a bright glow over lit nodes); re-adds after a style switch
useEffect(() => {
const map = mapRef.current;
if (!map || !isReady) return;
const accent = paletteVar("--palette-primary", "#3B82F6");
if (!map.getSource(PACKET_FLOW_SOURCE_ID)) {
map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC });
}
if (!map.getLayer(PACKET_FLOW_LAYER_ID)) {
// no beforeId: draw on top of the node markers so the moving pulse stays visible
// no beforeId: draw on top so the highlight pops over the dimmed markers
map.addLayer({
id: PACKET_FLOW_LAYER_ID,
type: "circle",
source: PACKET_FLOW_SOURCE_ID,
paint: {
"circle-radius": 5,
"circle-radius": 9,
"circle-color": accent,
"circle-opacity": ["get", "opacity"],
"circle-stroke-width": 1.5,
"circle-opacity": ["*", ["get", "opacity"], 0.85],
"circle-blur": 0.35,
"circle-stroke-width": 2,
"circle-stroke-color": accent,
"circle-stroke-opacity": ["*", ["get", "opacity"], 0.5],
"circle-stroke-opacity": ["get", "opacity"],
},
} as CircleLayerSpecification);
}
@@ -76,54 +81,47 @@ export function useMapPacketFlow(
map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent);
}, [mapRef, isReady, themeKey]);
// connection-wide resolvePath toggle: on when enabled, off on disable/unmount
// connection-wide resolvePath toggle: on while enabled, off otherwise
useEffect(() => {
wsManager.setResolvePath(enabled);
return () => wsManager.setResolvePath(false);
}, [enabled, wsManager]);
// feed observed resolved paths into new pulses; tear the animation down when disabled
// dim (or restore) the base node + cluster layers. Keyed on themeKey too so it re-applies after
// useMapNodes rebuilds its layers on a theme/style change (that hook runs first, resetting opacity).
useEffect(() => {
const map = mapRef.current;
if (!map || !isReady) return;
const iconOpacity = enabled ? LIVE_DIM_OPACITY : 1;
for (const id of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) {
if (map.getLayer(id)) map.setPaintProperty(id, "icon-opacity", iconOpacity);
}
if (map.getLayer(NODES_POINT_LAYER_ID)) {
map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY);
}
if (map.getLayer(NODES_CLUSTER_LAYER_ID)) {
map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1);
}
}, [mapRef, isReady, enabled, themeKey]);
// light up each observed packet's resolved-path nodes; tear down when disabled
useEffect(() => {
if (!enabled) return;
const map = mapRef.current; // stable for the component's life; used to clear the source on cleanup
const map = mapRef.current;
const lit = litRef.current; // stable Map for the component's life; used in the cleanup too
const unsub = wsManager.onPacketObservation((data) => {
const resolved = data.observation?.resolvedPath;
if (!resolved || resolved.length === 0) return;
const nodes = resolvedPathNodes(resolved);
if (nodes.length === 0) return;
const now = performance.now();
// many observers report the same packet — animate it once per dedup window
const seenAt = recentRef.current.get(data.packetHash);
if (seenAt != null && now - seenAt < PACKET_FLOW_DEDUP_MS) return;
const coords = resolvedPathToRoute(resolved);
if (coords.length < 2) return; // nothing to draw between
const { cumLengths, total } = routeMetrics(coords);
if (total === 0) return;
// record only after we know this observation produced a pulse, so a partially-resolved report
// doesn't suppress a later fully-resolved one for the same packet
recentRef.current.set(data.packetHash, now);
for (const [hash, ts] of recentRef.current) {
if (now - ts > PACKET_FLOW_DEDUP_MS) recentRef.current.delete(hash);
}
pulsesRef.current.push({
id: nextIdRef.current++,
coords,
cumLengths,
total,
startMs: now,
durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS,
});
if (pulsesRef.current.length > PACKET_FLOW_MAX_PULSES) {
pulsesRef.current.splice(0, pulsesRef.current.length - PACKET_FLOW_MAX_PULSES);
}
for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now });
startLoop();
});
return () => {
unsub();
pulsesRef.current = [];
lit.clear();
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
@@ -133,10 +131,9 @@ export function useMapPacketFlow(
};
}, [enabled, wsManager, mapRef, startLoop]);
// clear in-flight pulses when the region changes (their geometry came from the old dataset)
// clear highlights when the region changes (those nodes came from the old dataset)
useEffect(() => {
pulsesRef.current = [];
recentRef.current.clear();
litRef.current.clear();
const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined;
src?.setData(EMPTY_FC);
}, [resetKey, mapRef]);
+33 -40
View File
@@ -1,55 +1,48 @@
import { describe, it, expect } from "vitest";
import { resolvedPathToRoute, routeMetrics, positionAt, buildPulseFC } from "../../../src/features/map/packet-flow";
import { resolvedPathNodes, litOpacity, buildLitFC } from "../../../src/features/map/packet-flow";
import type { ResolvedHop } from "../../../src/types/api";
// a high-confidence hop resolved to one located node at [lng, lat]
function hop(lng: number, lat: number): ResolvedHop {
return { confidence: "high", nodes: [{ id: "n", publicKey: "pk", longitude: lng, latitude: lat }] };
// a high-confidence hop resolved to one located node
function hop(id: string, lng: number, lat: number): ResolvedHop {
return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] };
}
describe("resolvedPathToRoute", () => {
it("keeps located hops in order as [lng, lat] and drops coordless hops", () => {
const path: ResolvedHop[] = [hop(-75, 45), { confidence: "none", nodes: [] }, hop(-76, 46)];
expect(resolvedPathToRoute(path)).toEqual([[-75, 45], [-76, 46]]);
describe("resolvedPathNodes", () => {
it("returns each hop's first located node as { id, lng, lat }, in order", () => {
const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("b", -76, 46)];
expect(resolvedPathNodes(path)).toEqual([
{ id: "a", lng: -75, lat: 45 },
{ id: "b", lng: -76, lat: 46 },
]);
});
it("returns fewer than 2 points when the path has no drawable geometry", () => {
expect(resolvedPathToRoute([{ confidence: "none", nodes: [] }])).toEqual([]);
it("dedupes a node that appears on more than one hop", () => {
const path: ResolvedHop[] = [hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)];
expect(resolvedPathNodes(path).map((n) => n.id)).toEqual(["a", "b"]);
});
it("skips hops with no located candidate", () => {
const path: ResolvedHop[] = [{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }];
expect(resolvedPathNodes(path)).toEqual([]);
});
});
describe("routeMetrics + positionAt", () => {
it("interpolates endpoints and the midpoint of a straight segment", () => {
const coords: [number, number][] = [[0, 0], [10, 0]];
const { cumLengths, total } = routeMetrics(coords);
expect(total).toBe(10);
expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]);
expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 0]);
expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([5, 0]);
});
it("walks the correct segment on a multi-hop route", () => {
const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]];
const { cumLengths, total } = routeMetrics(coords);
expect(total).toBe(20);
expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([10, 0]); // the middle vertex
expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]);
describe("litOpacity", () => {
it("is full at the moment lit and eases to zero by fadeMs", () => {
expect(litOpacity(1000, 1000, 4000)).toBe(1); // just lit
expect(litOpacity(1000, 3000, 4000)).toBeCloseTo(0.5); // halfway
expect(litOpacity(1000, 5000, 4000)).toBe(0); // fully faded
expect(litOpacity(1000, 9000, 4000)).toBe(0); // past its life, clamped
});
});
describe("buildPulseFC", () => {
const coords: [number, number][] = [[0, 0], [10, 0]];
const { cumLengths, total } = routeMetrics(coords);
const pulse = { id: 1, coords, cumLengths, total, startMs: 1000, durationMs: 1000 };
it("places each pulse at its current position along the route", () => {
const fc = buildPulseFC([pulse], 1500); // halfway through
expect(fc.features).toHaveLength(1);
expect(fc.features[0]!.geometry.coordinates).toEqual([5, 0]);
});
it("keeps full opacity early and fades to zero at the end", () => {
expect(buildPulseFC([pulse], 1500).features[0]!.properties.opacity).toBe(1); // t=0.5
expect(buildPulseFC([pulse], 2000).features[0]!.properties.opacity).toBe(0); // t=1
describe("buildLitFC", () => {
it("emits one point feature per lit node with its current opacity", () => {
const lit = [{ lng: -75, lat: 45, litAt: 1000 }, { lng: -76, lat: 46, litAt: 3000 }];
const fc = buildLitFC(lit, 3000, 4000);
expect(fc.features).toHaveLength(2);
expect(fc.features[0]!.geometry.coordinates).toEqual([-75, 45]);
expect(fc.features[0]!.properties.opacity).toBeCloseTo(0.5); // lit at 1000, now 3000
expect(fc.features[1]!.properties.opacity).toBe(1); // just lit
});
});