mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 21:11:23 +00:00
map: shoot a comet along each packet's route in live mode
This commit is contained in:
1 parent
b5e2e5dcac
commit
a0736545d8
4 files changed
+188
-22
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user