map: shoot a comet along each packet's route in live mode

This commit is contained in:
MrAlders0n committed 2026-07-06 08:13:03 -04:00
1 parent b5e2e5dcac
commit a0736545d8
4 files changed
+188 -22

No files matched your search

+74
View File
@@ -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<Point, CometFeatureProps> {
const features: Feature<Point, CometFeatureProps>[] = [];
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 };
}
+4 -1
View File
@@ -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
+76 -20
View File
@@ -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<MapLibreMap | null>,
isReady: boolean,
@@ -35,33 +38,44 @@ export function useMapPacketFlow(
resetKey: string,
) {
const litRef = useRef<Map<string, LitNode>>(new Map());
const pulsesRef = useRef<Pulse[]>([]);
const nextIdRef = useRef(0);
const rafRef = useRef<number | null>(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
}
+34 -1
View File
@@ -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);
});
});