From a0736545d8b5264c80ec98536c83860eaef1ab25 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Jul 2026 08:13:03 -0400 Subject: [PATCH] map: shoot a comet along each packet's route in live mode --- src/features/map/packet-flow.ts | 74 ++++++++++++++++++++ src/features/map/types.ts | 5 +- src/features/map/useMapPacketFlow.ts | 96 ++++++++++++++++++++------ tests/features/map/packet-flow.test.ts | 35 +++++++++- 4 files changed, 188 insertions(+), 22 deletions(-) diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 57d82c2..798d239 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -46,3 +46,77 @@ export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): })); return { type: "FeatureCollection", features }; } + +// --- Comet: a bright head that shoots along the route, trailing a fading streak --- + +// Cumulative segment lengths (planar degrees — fine at mesh scale) so the head 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 }; +} + +// 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 comet. cumLengths/total are precomputed (routeMetrics) so each frame is an interpolation. +export interface Pulse { + id: number; + coords: [number, number][]; + cumLengths: number[]; + total: number; + startMs: number; + durationMs: number; +} + +export interface CometFeatureProps { + opacity: number; + head: number; // 1 for the bright head, 0 for trail points +} + +// Elapsed fraction of a comet's travel; >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; +} + +const COMET_TRAIL = 5; // trailing points behind the head +const COMET_STEP = 0.045; // spacing between trail points, as a fraction of travel + +// The comet head + a short trail behind it, each a point with its own opacity. The whole comet eases +// out over the final stretch so it fades as it reaches the last repeater. +export function buildCometFC(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 life = clamped < 0.85 ? 1 : Math.max(0, 1 - (clamped - 0.85) / 0.15); + for (let k = 0; k <= COMET_TRAIL; k++) { + const tk = clamped - k * COMET_STEP; + if (tk < 0) break; + const trail = 1 - k / (COMET_TRAIL + 1); // head brightest, tail dimmest + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, tk) }, + properties: { opacity: life * trail, head: k === 0 ? 1 : 0 }, + }); + } + } + return { type: "FeatureCollection", features }; +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts index e6dc191..1cafc30 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -96,10 +96,13 @@ 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: dim every node, then flash a packet's resolved-path nodes and fade them out --- +// --- Live packet-flow: dim every node, flash the resolved-path nodes, and shoot a comet along the route --- export const PACKET_FLOW_SOURCE_ID = "packet-flow"; export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes +export const PACKET_FLOW_COMET_SOURCE_ID = "packet-flow-comet"; +export const PACKET_FLOW_COMET_LAYER_ID = "packet-flow-comet"; // moving comet head + trail, on top export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this +export const PACKET_FLOW_SEGMENT_MS = 650; // comet travel time per hop segment export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on export const CLUSTER_RADIUS = 50; // px diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index ffc99c4..2e32cbe 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -2,11 +2,14 @@ import { useCallback, useEffect, useRef } from "react"; import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl"; import type { FeatureCollection } from "geojson"; import type { WsManager } from "../../api/ws-manager"; -import { resolvedPathNodes, buildLitFC, type LitNode } from "./packet-flow"; +import { resolvedPathNodes, buildLitFC, routeMetrics, buildCometFC, pulseProgress, type LitNode, type Pulse } from "./packet-flow"; import { PACKET_FLOW_SOURCE_ID, PACKET_FLOW_LAYER_ID, + PACKET_FLOW_COMET_SOURCE_ID, + PACKET_FLOW_COMET_LAYER_ID, PACKET_FLOW_FADE_MS, + PACKET_FLOW_SEGMENT_MS, LIVE_DIM_OPACITY, NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, @@ -22,10 +25,10 @@ function paletteVar(name: string, fallback: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } -// 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. +// Live mode: dim every node/cluster to near-invisible; on each observed packet, flash its resolved-path +// nodes to full opacity (fading over PACKET_FLOW_FADE_MS) and shoot a comet along the route. Enabling +// it opts the WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the +// maplibre layers, the dimming, the rAF loop, and the subscription. export function useMapPacketFlow( mapRef: React.RefObject, isReady: boolean, @@ -35,33 +38,44 @@ export function useMapPacketFlow( resetKey: string, ) { const litRef = useRef>(new Map()); + const pulsesRef = useRef([]); + const nextIdRef = useRef(0); const rafRef = useRef(null); - // fade loop: recompute each lit node's opacity, drop the fully-faded, stop once none remain + // one animation frame: fade the lit nodes and advance the comets; keep going while either has work const startLoop = useCallback(() => { if (rafRef.current != null) return; function frame() { + const map = mapRef.current; const now = performance.now(); + 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; + const litSrc = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + if (litSrc) litSrc.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS)); + + pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1); + const cometSrc = map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined; + if (cometSrc) cometSrc.setData(buildCometFC(pulsesRef.current, now)); + + const busy = litRef.current.size > 0 || pulsesRef.current.length > 0; + rafRef.current = busy ? requestAnimationFrame(frame) : null; } rafRef.current = requestAnimationFrame(frame); }, [mapRef]); - // build the highlight source + layer (a bright glow over lit nodes); re-adds after a style switch + // build both layers: the node-highlight glow and the comet (head + trail). 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 so the highlight pops over the dimmed markers + // no beforeId: draw over the dimmed markers map.addLayer({ id: PACKET_FLOW_LAYER_ID, type: "circle", @@ -79,6 +93,25 @@ export function useMapPacketFlow( } map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-color", accent); map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent); + + // comet on top of the glow + if (!map.getSource(PACKET_FLOW_COMET_SOURCE_ID)) { + map.addSource(PACKET_FLOW_COMET_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_COMET_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_COMET_LAYER_ID, + type: "circle", + source: PACKET_FLOW_COMET_SOURCE_ID, + paint: { + "circle-radius": ["case", ["==", ["get", "head"], 1], 5, 3], + "circle-color": accent, + "circle-opacity": ["get", "opacity"], + "circle-blur": 0.5, + }, + } as CircleLayerSpecification); + } + map.setPaintProperty(PACKET_FLOW_COMET_LAYER_ID, "circle-color", accent); }, [mapRef, isReady, themeKey]); // connection-wide resolvePath toggle: on while enabled, off otherwise @@ -104,7 +137,7 @@ export function useMapPacketFlow( } }, [mapRef, isReady, enabled, themeKey]); - // light up each observed packet's resolved-path nodes; tear down when disabled + // per observed packet: flash its route's nodes and launch a comet along the route useEffect(() => { if (!enabled) return; const map = mapRef.current; @@ -115,30 +148,49 @@ export function useMapPacketFlow( const nodes = resolvedPathNodes(resolved); if (nodes.length === 0) return; const now = performance.now(); + for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now }); + + if (nodes.length >= 2) { + const coords = nodes.map((n) => [n.lng, n.lat] as [number, number]); + const { cumLengths, total } = routeMetrics(coords); + if (total > 0) { + pulsesRef.current.push({ + id: nextIdRef.current++, + coords, + cumLengths, + total, + startMs: now, + durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS, + }); + } + } startLoop(); }); return () => { unsub(); lit.clear(); + pulsesRef.current = []; if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } - const src = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - src?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); }; }, [enabled, wsManager, mapRef, startLoop]); - // clear highlights when the region changes (those nodes came from the old dataset) + // clear highlights + comets when the region changes (they came from the old dataset) useEffect(() => { litRef.current.clear(); - const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - src?.setData(EMPTY_FC); + pulsesRef.current = []; + const map = mapRef.current; + (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); }, [resetKey, mapRef]); - // remove the layer + source on unmount (runs before useMapLibre's map.remove()) + // remove both layers + sources on unmount (runs before useMapLibre's map.remove()) useEffect(() => { const map = mapRef.current; return () => { @@ -146,8 +198,12 @@ export function useMapPacketFlow( rafRef.current = null; if (!map) return; try { - if (map.getLayer(PACKET_FLOW_LAYER_ID)) map.removeLayer(PACKET_FLOW_LAYER_ID); - if (map.getSource(PACKET_FLOW_SOURCE_ID)) map.removeSource(PACKET_FLOW_SOURCE_ID); + for (const id of [PACKET_FLOW_LAYER_ID, PACKET_FLOW_COMET_LAYER_ID]) { + if (map.getLayer(id)) map.removeLayer(id); + } + for (const id of [PACKET_FLOW_SOURCE_ID, PACKET_FLOW_COMET_SOURCE_ID]) { + if (map.getSource(id)) map.removeSource(id); + } } catch { // map may already be torn down } diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 9baebc1..d8e6ea0 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathNodes, litOpacity, buildLitFC } from "../../../src/features/map/packet-flow"; +import { resolvedPathNodes, litOpacity, buildLitFC, routeMetrics, positionAt, buildCometFC } from "../../../src/features/map/packet-flow"; import type { ResolvedHop } from "../../../src/types/api"; // a high-confidence hop resolved to one located node @@ -46,3 +46,36 @@ describe("buildLitFC", () => { expect(fc.features[1]!.properties.opacity).toBe(1); // just lit }); }); + +describe("routeMetrics + positionAt", () => { + it("measures cumulative length and interpolates along the 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)).toEqual([0, 0]); + expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 10]); + expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]); + }); +}); + +describe("buildCometFC", () => { + const coords: [number, number][] = [[0, 0], [10, 0]]; + const { cumLengths, total } = routeMetrics(coords); + const pulse = { id: 1, coords, cumLengths, total, startMs: 0, durationMs: 1000 }; + + it("emits a bright head at the current position plus a dimmer trail behind it", () => { + const fc = buildCometFC([pulse], 500); // halfway + const head = fc.features[0]!; + expect(head.properties.head).toBe(1); + expect(head.geometry.coordinates[0]).toBeCloseTo(5, 5); // midpoint of the segment + expect(fc.features.length).toBeGreaterThan(1); // has a trail + expect(fc.features[1]!.properties.head).toBe(0); + expect(fc.features[1]!.properties.opacity).toBeLessThan(head.properties.opacity); + }); + + it("fades the comet out as it arrives at the end", () => { + const near = buildCometFC([pulse], 990).features[0]!.properties.opacity; // ~arrived + const mid = buildCometFC([pulse], 400).features[0]!.properties.opacity; + expect(near).toBeLessThan(mid); + }); +});