diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 389a0aa..57d82c2 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -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(); + 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 { - const features: Feature[] = []; - 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 { + const features: Feature[] = litNodes.map((n) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [n.lng, n.lat] }, + properties: { opacity: litOpacity(n.litAt, nowMs, fadeMs) }, + })); return { type: "FeatureCollection", features }; } diff --git a/src/features/map/types.ts b/src/features/map/types.ts index ab6c672..e6dc191 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -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 diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 8d51b4d..ffc99c4 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -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, isReady: boolean, @@ -28,47 +34,46 @@ export function useMapPacketFlow( themeKey: string, resetKey: string, ) { - const pulsesRef = useRef([]); + const litRef = useRef>(new Map()); const rafRef = useRef(null); - const nextIdRef = useRef(0); - const recentRef = useRef>(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]); diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 8826041..9baebc1 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -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 }); });