From a5b0e8ce694c965812499a48f1833d0f3ade203e Mon Sep 17 00:00:00 2001 From: SimmerV Date: Sun, 7 Jun 2026 17:40:10 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(frontend):=20live=20packet-arc=20anima?= =?UTF-8?q?tion=20on=20the=20map=20(real=20from=E2=86=92sender=20fan-in=20?= =?UTF-8?q?over=20SSE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Map.tsx | 112 ++++++ frontend/src/pages/map/activityLayer.ts | 341 ++++++++++++++++++ frontend/src/pages/map/clusterDonutLayer.ts | 34 +- .../src/pages/map/packetCoalescer.test.ts | 49 +++ frontend/src/pages/map/packetCoalescer.ts | 71 ++++ frontend/src/pages/map/packetColors.ts | 19 + frontend/src/pages/map/storage.ts | 2 + 7 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/map/activityLayer.ts create mode 100644 frontend/src/pages/map/packetCoalescer.test.ts create mode 100644 frontend/src/pages/map/packetCoalescer.ts create mode 100644 frontend/src/pages/map/packetColors.ts diff --git a/frontend/src/pages/Map.tsx b/frontend/src/pages/Map.tsx index b51c8fab..57b033df 100644 --- a/frontend/src/pages/Map.tsx +++ b/frontend/src/pages/Map.tsx @@ -8,11 +8,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "../components/toastStore"; import { env } from "../env"; +import { useLiveEvent } from "../hooks/useLiveEvent"; import { reverseGeocode } from "../maps/geocoder"; import { buildMapStyle, ensureBuildings3D, ensureTerrain, isDarkBasemap, type OsmBasemap, removeBuildings3D, removeTerrain } from "../maps/mapStyle"; import { useGetConfigQuery, useGetNodesQuery, useGetTraceroutesQuery } from "../slices/apiSlice"; import { NodeRole, roleTitles } from "../types"; import { convertNodeIdFromIntToHex } from "../utils/convertNodeId"; +import { prefersReducedMotion } from "../utils/reducedMotion"; +import { ActivityLayer } from "./map/activityLayer"; import { ClusterDonutLayer } from "./map/clusterDonutLayer"; import { type ClusterHover,ClusterHoverCard } from "./map/ClusterHoverCard"; import { FiltersResetPill } from "./map/FiltersResetPill"; @@ -29,6 +32,8 @@ import { MapSearchBar } from "./map/MapSearchBar"; import { MapSettingsPanel } from "./map/MapSettingsPanel"; import { MapToolPrompt, MapToolsDrawer } from "./map/MapToolsDrawer"; import { MapTraceroutePanel } from "./map/MapTraceroutePanel"; +import { type PacketArc, PacketCoalescer, type RawPacket } from "./map/packetCoalescer"; +import { packetColor } from "./map/packetColors"; import { findPathsBetween } from "./map/pathAnalysis"; import { autoSpiderfyOverlappingPlainNodes, @@ -64,6 +69,19 @@ import { ROLE_COLORS, } from "./map/utils"; +// Cap arcs spawned per flush so a burst can't stall a frame (drop oldest excess). +const MAX_ARCS_PER_FLUSH = 40; + +const samePoint = (a: [number, number], b: [number, number]) => a[0] === b[0] && a[1] === b[1]; + +/** Map a reported signal to a 0..1 arc intensity — prefer SNR, fall back to RSSI. */ +function packetWeight(rssi?: number, snr?: number): number { + const clamp = (v: number) => Math.max(0.15, Math.min(1, v)); + if (typeof snr === "number") return clamp((snr + 20) / 30); + if (typeof rssi === "number") return clamp((rssi + 120) / 90); + return 0.5; +} + export function Map() { const mapRef = useRef(null); @@ -86,6 +104,11 @@ export function Map() { const mbSelectedIdRef = useRef(null); // Last node-source signature; skips redundant setData. -1 = never set. const lastNodesSigRef = useRef(-1); + // Live packet-arc animation plumbing. + const activityLayerRef = useRef(null); + const coalescerRef = useRef(null); + const pendingArcsRef = useRef([]); + const flushRafRef = useRef(null); const mbHandlersBoundRef = useRef(false); const mbCurrentStyleUrlRef = useRef(null); const mbKeydownHandlerRef = useRef<((e: KeyboardEvent) => void) | null>(null); @@ -137,6 +160,7 @@ export function Map() { return stored ?? 30; }); + const [livePackets, setLivePackets] = useState(() => readJson(LS_KEYS.livePackets, true)); const [clusterEnabled, setClusterEnabled] = useState(() => { const stored = readJson(LS_KEYS.clusterEnabled, null); return stored ?? true; @@ -195,6 +219,7 @@ export function Map() { useEffect(() => writeJson(LS_KEYS.osmBasemap, osmBasemap), [osmBasemap]); useEffect(() => writeJson(LS_KEYS.recentDays, recentDays), [recentDays]); useEffect(() => writeJson(LS_KEYS.clusterEnabled, clusterEnabled), [clusterEnabled]); + useEffect(() => writeJson(LS_KEYS.livePackets, livePackets), [livePackets]); useEffect(() => writeJson(LS_KEYS.linkMode, linkMode), [linkMode]); useEffect(() => writeJson(LS_KEYS.myNodeId, myNodeId), [myNodeId]); useEffect(() => writeJson(LS_KEYS.settingsPanelOpen, settingsPanelOpen), [settingsPanelOpen]); @@ -307,6 +332,7 @@ export function Map() { const configRef = useRef(config); const recentDaysRef = useRef(recentDays); const clusterEnabledRef = useRef(clusterEnabled); + const livePacketsRef = useRef(livePackets); const linkModeRef = useRef(linkMode); const myNodeIdRef = useRef(myNodeId); const roleFilterRef = useRef(roleFilter); @@ -329,6 +355,7 @@ export function Map() { useEffect(() => { setDetailsDataRef.current = setDetailsData; }, [setDetailsData]); useEffect(() => { recentDaysRef.current = recentDays; }, [recentDays]); useEffect(() => { clusterEnabledRef.current = clusterEnabled; }, [clusterEnabled]); + useEffect(() => { livePacketsRef.current = livePackets; }, [livePackets]); useEffect(() => { linkModeRef.current = linkMode; }, [linkMode]); useEffect(() => { roleFilterRef.current = roleFilter; }, [roleFilter]); useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]); @@ -1429,6 +1456,13 @@ export function Map() { }); } + // Live packet activity (custom WebGL layer; drawn above nodes) + if (!map.getLayer("activity")) { + const al = new ActivityLayer(); + map.addLayer(al); + activityLayerRef.current = al; + } + // Apply current cluster visibility (use ref to avoid stale closure) applyClusterVisibility(map, clusterEnabledRef.current); @@ -2334,6 +2368,74 @@ export function Map() { } }, [nodes, recentDays, roleFilter, channelFilter]); + // Live packet arcs: resolve from→sender positions and spawn into the layer. + const flushPacketArcs = useCallback(() => { + flushRafRef.current = null; + const layer = activityLayerRef.current; + const all = pendingArcsRef.current; + pendingArcsRef.current = []; + if (!layer || all.length === 0) return; + // Drop oldest excess on a burst; keep the most recent so the feed stays live. + const arcs = all.length > MAX_ARCS_PER_FLUSH ? all.slice(-MAX_ARCS_PER_FLUSH) : all; + const liveNodes = nodesRef.current; + const now = performance.now(); + + // With clustering on, snap endpoints to the donut that visually covers them so + // arcs line up with the clusters on screen. Project cluster centroids once. + const map = mbMapRef.current; + const projected = + clusterEnabledRef.current && map && clusterDonutLayerRef.current + ? clusterDonutLayerRef.current.visibleClusters().map((c) => { + const p = map.project([c.lng, c.lat]); + return { x: p.x, y: p.y, r: c.r, lngLat: [c.lng, c.lat] as [number, number] }; + }) + : null; + const anchor = (pos: [number, number]): [number, number] => { + if (!projected || !map) return pos; + const p = map.project(pos); + let best: [number, number] | null = null; + let bestD = Infinity; + for (const c of projected) { + const d = Math.hypot(c.x - p.x, c.y - p.y); + if (d <= c.r && d < bestD) { + bestD = d; + best = c.lngLat; + } + } + return best ?? pos; + }; + + for (const a of arcs) { + const fromRaw = liveNodes[a.fromId]?.map_position; + const senderRaw = liveNodes[a.senderId]?.map_position; + const fromPos = fromRaw ? anchor(fromRaw) : undefined; + const senderPos = senderRaw ? anchor(senderRaw) : undefined; + const color = packetColor(a.type); + if (a.isNewTransmission && fromPos) layer.spawnPulse(fromPos, color, now); + if (fromPos && senderPos && !samePoint(fromPos, senderPos)) { + layer.spawnArc(fromPos, senderPos, color, packetWeight(a.rssi, a.snr), now); + } else if (!fromPos && senderPos) { + layer.spawnRipple(senderPos, color, now); // heard, origin position unknown + } + } + }, []); + + useLiveEvent("packet", (p) => { + if (!livePacketsRef.current || prefersReducedMotion()) return; + const coalescer = (coalescerRef.current ??= new PacketCoalescer()); + const arc = coalescer.ingest(p, Date.now()); + if (!arc) return; + pendingArcsRef.current.push(arc); + if (flushRafRef.current == null) flushRafRef.current = requestAnimationFrame(flushPacketArcs); + }); + + useEffect( + () => () => { + if (flushRafRef.current != null) cancelAnimationFrame(flushRafRef.current); + }, + [], + ); + // React to linkMode / myNodeId / nodes changes for persistent links useEffect(() => { const map = mbMapRef.current; @@ -2490,6 +2592,16 @@ export function Map() { + + {/* Live terrain elevation under the cursor — helps sanity-check coverage paints. Only renders when 3D terrain is on and we got a valid sample. */} {terrain3D && hoverElevationM != null && ( diff --git a/frontend/src/pages/map/activityLayer.ts b/frontend/src/pages/map/activityLayer.ts new file mode 100644 index 00000000..d1592a8e --- /dev/null +++ b/frontend/src/pages/map/activityLayer.ts @@ -0,0 +1,341 @@ +/** WebGL layer for live packet arcs (comets), origin pulses, and gateway ripples. + * Time-in-shader over a persistent ring buffer; self-stops when idle. */ +import maplibregl, { type CustomRenderMethodInput } from "maplibre-gl"; + +type RGB = [number, number, number]; +type LngLat = [number, number]; + +const FLOATS = 11; // pos.xyz | s | t0 | dur | color.rgb | kind | weight +const STRIDE = FLOATS * 4; +const MAX_POINTS = 16_384; +const ARC_SAMPLES = 28; +const ARC_MS = 1800; +const PULSE_MS = 750; +const RIPPLE_MS = 750; +const FRAME_MS = 1000 / 30; // keep-alive repaint cap (~30fps) + +const VS = ` +attribute vec3 a_pos; +attribute float a_s; +attribute float a_t0; +attribute float a_dur; +attribute vec3 a_color; +attribute float a_kind; // 0 = arc dot, 1 = ring +attribute float a_weight; + +uniform mat4 u_matrix; +uniform float u_now; +uniform float u_dpr; +uniform float u_alpha; + +varying float v_alpha; +varying vec3 v_color; +varying float v_kind; + +void main() { + float phase = (u_now - a_t0) / a_dur; + if (phase < 0.0 || phase > 1.0) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); // offscreen → culled + gl_PointSize = 0.0; + v_alpha = 0.0; + return; + } + + gl_Position = u_matrix * vec4(a_pos, 1.0); + + float size; + float alpha; + if (a_kind < 0.5) { + // bright head at phase, fading trail behind + float trail = 0.32; + float d = phase - a_s; + float vis = (d >= 0.0 && d <= trail) ? (1.0 - d / trail) : 0.0; + float globalFade = 1.0 - smoothstep(0.85, 1.0, phase); + size = (3.0 + 7.0 * vis) * mix(0.6, 1.0, a_weight) * u_dpr; + alpha = vis * globalFade * mix(0.5, 1.0, a_weight); + } else { + float e = 1.0 - pow(1.0 - phase, 2.0); + size = mix(6.0, 34.0, e) * u_dpr; + alpha = (1.0 - phase) * 0.9; + } + + gl_PointSize = size; + v_alpha = alpha * u_alpha; + v_color = a_color; + v_kind = a_kind; +} +`; + +const FS = ` +precision mediump float; +varying float v_alpha; +varying vec3 v_color; +varying float v_kind; +void main() { + if (v_alpha <= 0.01) discard; + float r = length(gl_PointCoord - vec2(0.5)) * 2.0; + float a; + if (v_kind < 0.5) { + a = 1.0 - smoothstep(0.4, 1.0, r); // soft dot + } else { + a = smoothstep(0.7, 0.85, r) - smoothstep(0.92, 1.0, r); // ring + } + gl_FragColor = vec4(v_color, a * v_alpha); +} +`; + +function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader { + const s = gl.createShader(type); + if (!s) throw new Error("Failed to create shader"); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s); + gl.deleteShader(s); + throw new Error(`Activity shader compile error: ${log ?? "(no log)"}`); + } + return s; +} + +function haversineM(lat0: number, lng0: number, lat1: number, lng1: number): number { + const R = 6371000; + const dLat = ((lat1 - lat0) * Math.PI) / 180; + const dLng = ((lng1 - lng0) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat0 * Math.PI) / 180) * Math.cos((lat1 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// Inline Mercator (allocation-free; bit-exact with MercatorCoordinate.fromLngLat). +const PI = Math.PI; +const EARTH_CIRCUMFERENCE = 2 * PI * 6371008.8; +const mercX = (lng: number) => (180 + lng) / 360; +const mercY = (lat: number) => { + const c = Math.max(-89.9, Math.min(89.9, lat)); + return (180 - (180 / PI) * Math.log(Math.tan(PI / 4 + (c * PI) / 360))) / 360; +}; +const mercZ = (alt: number, lat: number) => { + const c = Math.max(-89.9, Math.min(89.9, lat)); + return alt / (EARTH_CIRCUMFERENCE * Math.cos((c * PI) / 180)); +}; + +export class ActivityLayer implements maplibregl.CustomLayerInterface { + readonly id = "activity"; + readonly type = "custom" as const; + readonly renderingMode = "2d" as const; + + private map: maplibregl.Map | null = null; + private gl: WebGLRenderingContext | null = null; + private program: WebGLProgram | null = null; + private buffer: WebGLBuffer | null = null; + private cpu = new Float32Array(MAX_POINTS * FLOATS); + private writeHead = 0; + private maxExpiry = 0; // performance.now() ms of the last live primitive + private alpha = 1; + private repaintTimer: ReturnType | null = null; + + private scratchArc = new Float32Array(ARC_SAMPLES * FLOATS); + private scratchRing = new Float32Array(FLOATS); + + private aPos = -1; + private aS = -1; + private aT0 = -1; + private aDur = -1; + private aColor = -1; + private aKind = -1; + private aWeight = -1; + private uMatrix: WebGLUniformLocation | null = null; + private uNow: WebGLUniformLocation | null = null; + private uDpr: WebGLUniformLocation | null = null; + private uAlpha: WebGLUniformLocation | null = null; + + onAdd(map: maplibregl.Map, gl: WebGLRenderingContext): void { + this.map = map; + this.gl = gl; + + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create activity program"); + gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VS)); + gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FS)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Activity program link error: ${gl.getProgramInfoLog(program) ?? "(no log)"}`); + } + this.program = program; + this.aPos = gl.getAttribLocation(program, "a_pos"); + this.aS = gl.getAttribLocation(program, "a_s"); + this.aT0 = gl.getAttribLocation(program, "a_t0"); + this.aDur = gl.getAttribLocation(program, "a_dur"); + this.aColor = gl.getAttribLocation(program, "a_color"); + this.aKind = gl.getAttribLocation(program, "a_kind"); + this.aWeight = gl.getAttribLocation(program, "a_weight"); + this.uMatrix = gl.getUniformLocation(program, "u_matrix"); + this.uNow = gl.getUniformLocation(program, "u_now"); + this.uDpr = gl.getUniformLocation(program, "u_dpr"); + this.uAlpha = gl.getUniformLocation(program, "u_alpha"); + + // init all slots dead (culled by the shader) + for (let i = 0; i < MAX_POINTS; i++) { + this.cpu[i * FLOATS + 4] = -1e12; + this.cpu[i * FLOATS + 5] = 1; + } + this.buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + gl.bufferData(gl.ARRAY_BUFFER, this.cpu, gl.DYNAMIC_DRAW); + } + + onRemove(_map: maplibregl.Map, gl: WebGLRenderingContext): void { + if (this.repaintTimer != null) clearTimeout(this.repaintTimer); + this.repaintTimer = null; + if (this.buffer) gl.deleteBuffer(this.buffer); + if (this.program) gl.deleteProgram(this.program); + this.buffer = null; + this.program = null; + this.gl = null; + this.map = null; + } + + /** Layer-wide opacity (0..1) — dim while an RF tool is active, like the donut. */ + setAlpha(a: number): void { + this.alpha = Math.max(0, Math.min(1, a)); + this.map?.triggerRepaint(); + } + + /** Request the next animation frame at ~30fps, single timer in flight. */ + private scheduleNextFrame(): void { + if (this.repaintTimer != null || !this.map) return; + this.repaintTimer = setTimeout(() => { + this.repaintTimer = null; + this.map?.triggerRepaint(); + }, FRAME_MS); + } + + private elevAt(lng: number, lat: number): number { + const map = this.map; + if (!map || !map.getTerrain?.()) return 0; + const e = map.queryTerrainElevation?.({ lng, lat }); + return Number.isFinite(e) ? (e as number) : 0; + } + + private writePoints(data: Float32Array, count: number): void { + const gl = this.gl; + if (!gl || !this.buffer) return; + let start = this.writeHead; + if (start + count > MAX_POINTS) start = 0; // wrap, overwriting oldest + this.cpu.set(data.subarray(0, count * FLOATS), start * FLOATS); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + gl.bufferSubData(gl.ARRAY_BUFFER, start * STRIDE, data.subarray(0, count * FLOATS)); + this.writeHead = start + count; + } + + /** from→sender comet + a gateway ripple on arrival. */ + spawnArc(from: LngLat, to: LngLat, color: RGB, weight: number, now: number): void { + if (!this.gl || !this.buffer) return; + const [lng0, lat0] = from; + const [lng1, lat1] = to; + const e0 = this.elevAt(lng0, lat0); + const e1 = this.elevAt(lng1, lat1); + const lift = Math.min(Math.max(haversineM(lat0, lng0, lat1, lng1) * 0.12, 250), 35000); + + const dxl = lng1 - lng0; + const dyl = lat1 - lat0; + const len = Math.hypot(dxl, dyl) || 1e-6; + const bow = 0.18 * len; // horizontal bow so it arcs even top-down + const lngC = (lng0 + lng1) / 2 + (-dyl / len) * bow; + const latC = (lat0 + lat1) / 2 + (dxl / len) * bow; + const altC = (e0 + e1) / 2 + lift; + + const d = this.scratchArc; + let o = 0; + for (let i = 0; i < ARC_SAMPLES; i++) { + const s = i / (ARC_SAMPLES - 1); + const om = 1 - s; + const lng = om * om * lng0 + 2 * om * s * lngC + s * s * lng1; + const lat = om * om * lat0 + 2 * om * s * latC + s * s * lat1; + const alt = om * om * e0 + 2 * om * s * altC + s * s * e1; + d[o++] = mercX(lng); + d[o++] = mercY(lat); + d[o++] = mercZ(alt, lat); + d[o++] = s; + d[o++] = now; + d[o++] = ARC_MS; + d[o++] = color[0]; + d[o++] = color[1]; + d[o++] = color[2]; + d[o++] = 0; + d[o++] = weight; + } + this.writePoints(d, ARC_SAMPLES); + this.maxExpiry = Math.max(this.maxExpiry, now + ARC_MS); + this.spawnRing(to, color, now + ARC_MS * 0.82, RIPPLE_MS, weight); + this.scheduleNextFrame(); + } + + spawnPulse(at: LngLat, color: RGB, now: number): void { + this.spawnRing(at, color, now, PULSE_MS, 1); + } + + spawnRipple(at: LngLat, color: RGB, now: number): void { + this.spawnRing(at, color, now, RIPPLE_MS, 1); + } + + private spawnRing(at: LngLat, color: RGB, t0: number, dur: number, weight: number): void { + if (!this.gl || !this.buffer) return; + const alt = this.elevAt(at[0], at[1]); + const d = this.scratchRing; + d[0] = mercX(at[0]); + d[1] = mercY(at[1]); + d[2] = mercZ(alt, at[1]); + d[3] = 0; + d[4] = t0; + d[5] = dur; + d[6] = color[0]; + d[7] = color[1]; + d[8] = color[2]; + d[9] = 1; + d[10] = weight; + this.writePoints(d, 1); + this.maxExpiry = Math.max(this.maxExpiry, t0 + dur); + this.scheduleNextFrame(); + } + + render(gl: WebGLRenderingContext | WebGL2RenderingContext, options: CustomRenderMethodInput): void { + if (!this.program || !this.buffer) return; + const now = performance.now(); + if (now > this.maxExpiry) return; // nothing alive → idle (no repaint) + + const tr = (this.map as unknown as { transform?: { mercatorMatrix?: Float32List | number[] } })?.transform; + const matrix = (tr?.mercatorMatrix ?? options.modelViewProjectionMatrix) as Float32List; + + gl.useProgram(this.program); + gl.uniformMatrix4fv(this.uMatrix, false, matrix); + gl.uniform1f(this.uNow, now); + gl.uniform1f(this.uDpr, window.devicePixelRatio || 1); + gl.uniform1f(this.uAlpha, this.alpha); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); + const set = (loc: number, size: number, offset: number) => { + if (loc < 0) return; + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE, offset); + }; + set(this.aPos, 3, 0); + set(this.aS, 1, 12); + set(this.aT0, 1, 16); + set(this.aDur, 1, 20); + set(this.aColor, 3, 24); + set(this.aKind, 1, 36); + set(this.aWeight, 1, 40); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // additive glow + gl.drawArrays(gl.POINTS, 0, MAX_POINTS); + + for (const loc of [this.aPos, this.aS, this.aT0, this.aDur, this.aColor, this.aKind, this.aWeight]) { + if (loc >= 0) gl.disableVertexAttribArray(loc); + } + + this.scheduleNextFrame(); // keep animating (~30fps) until maxExpiry + } +} diff --git a/frontend/src/pages/map/clusterDonutLayer.ts b/frontend/src/pages/map/clusterDonutLayer.ts index e87acefa..1b284044 100644 --- a/frontend/src/pages/map/clusterDonutLayer.ts +++ b/frontend/src/pages/map/clusterDonutLayer.ts @@ -148,6 +148,10 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { /** Per-cluster ratio tween (keyed by rounded position, like the dedupe). */ private anim = new Map(); private static readonly RATIO_TWEEN_MS = 500; + /** Camera fingerprint of the last terrain re-drape; skips redundant per-frame re-drape. */ + private lastCamSig = ""; + /** Reused vertex buffer; reallocated only when the cluster count grows. */ + private vertScratch: Float32Array | null = null; private onMoveend: (() => void) | null = null; private onIdle: (() => void) | null = null; @@ -204,6 +208,7 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { // Kick a repaint when DEM tiles arrive on an idle map; render's per-frame // z-refresh picks up the new elevation. No dirty — cluster set is unchanged. if (e.sourceId === MAP_STYLE_IDS.terrainSource) { + this.lastCamSig = ""; // force one re-drape with the new elevation map.triggerRepaint(); } }; @@ -231,6 +236,7 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { this.onIdle = null; this.onSourceData = null; this.anim.clear(); + this.vertScratch = null; } /** Layer-wide opacity multiplier (0..1). Used to dim donuts when an RF tool is active. */ @@ -241,6 +247,11 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { this.map?.triggerRepaint(); } + /** Current viewport cluster centroids + pixel radius (lets arcs snap to clusters). */ + visibleClusters(): { lng: number; lat: number; r: number }[] { + return this.lastClusters.map((c) => ({ lng: c.lng, lat: c.lat, r: c.r })); + } + private rebuild(): void { const map = this.map; if (!map) return; @@ -328,7 +339,9 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { // 6 verts/cluster × 7 floats: x, y, z, ux, uy, r, ratio const floatsPerVertex = 7; const vertsPerCluster = 6; - const verts = new Float32Array(this.lastClusters.length * vertsPerCluster * floatsPerVertex); + const needed = this.lastClusters.length * vertsPerCluster * floatsPerVertex; + if (!this.vertScratch || this.vertScratch.length < needed) this.vertScratch = new Float32Array(needed); + const verts = this.vertScratch; const corners: [number, number][] = [ [-1, 1], [-1, -1], [ 1, -1], // UL, LL, LR @@ -353,7 +366,7 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { } gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer); - gl.bufferData(gl.ARRAY_BUFFER, verts, gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, verts.subarray(0, needed), gl.DYNAMIC_DRAW); this.vertexCount = this.lastClusters.length * vertsPerCluster; } @@ -361,14 +374,25 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { if (!this.program || !this.buffer) return; const now = performance.now(); + const map = this.map; + const terrainOn = !!map?.getTerrain?.(); + // Terrain draping depends on center/zoom/pitch/bearing; only re-drape when one changes. + let camSig = ""; + if (terrainOn && map) { + const c = map.getCenter(); + camSig = `${c.lng.toFixed(5)},${c.lat.toFixed(5)},${map.getZoom().toFixed(3)},${map.getPitch().toFixed(2)},${map.getBearing().toFixed(2)}`; + } // Rebuild inside render() so queryRenderedFeatures sees current tile state if (this.dirty) { this.rebuild(); this.dirty = false; - } else if (this.hasActiveTween(now) || (this.map?.getTerrain?.() && this.lastClusters.length > 0)) { - // Re-evaluate per frame while a ratio tween runs, or to re-drape on terrain. - this.uploadVerts(); + this.lastCamSig = camSig; + } else if (this.hasActiveTween(now)) { + this.uploadVerts(); // ratio tween: re-evaluate every frame + } else if (terrainOn && this.lastClusters.length > 0 && camSig !== this.lastCamSig) { + this.uploadVerts(); // camera moved (or DEM arrived): re-drape once + this.lastCamSig = camSig; } if (this.vertexCount === 0) return; diff --git a/frontend/src/pages/map/packetCoalescer.test.ts b/frontend/src/pages/map/packetCoalescer.test.ts new file mode 100644 index 00000000..b4686e0e --- /dev/null +++ b/frontend/src/pages/map/packetCoalescer.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { PacketCoalescer } from "./packetCoalescer"; + +const T0 = 1_000_000; + +describe("PacketCoalescer", () => { + it("first sighting is a new transmission with normalized ids", () => { + const c = new PacketCoalescer(); + const arc = c.ingest({ id: 7, from: 1128082076, sender: "!aabbccdd", type: "text" }, T0); + expect(arc).toMatchObject({ fromId: "433d2a9c", senderId: "aabbccdd", type: "text", isNewTransmission: true }); + }); + + it("same id, different gateway → arc only (fan-in, not new)", () => { + const c = new PacketCoalescer(); + c.ingest({ id: 7, from: 10, sender: "aaaaaaaa" }, T0); + const arc = c.ingest({ id: 7, from: 10, sender: "bbbbbbbb" }, T0 + 500); + expect(arc).toMatchObject({ senderId: "bbbbbbbb", isNewTransmission: false }); + }); + + it("same id, same gateway → dropped duplicate", () => { + const c = new PacketCoalescer(); + c.ingest({ id: 7, from: 10, sender: "aaaaaaaa" }, T0); + expect(c.ingest({ id: 7, from: 10, sender: "aaaaaaaa" }, T0 + 100)).toBeNull(); + }); + + it("rejects missing/broadcast endpoints", () => { + const c = new PacketCoalescer(); + expect(c.ingest({ id: 1, from: 10 }, T0)).toBeNull(); // no sender + expect(c.ingest({ id: 1, sender: "aaaaaaaa" }, T0)).toBeNull(); // no from + expect(c.ingest({ id: 1, from: 4294967295, sender: "aaaaaaaa" }, T0)).toBeNull(); // broadcast from + expect(c.ingest({ id: 1, from: 10, sender: "ffffffff" }, T0)).toBeNull(); // broadcast gateway + }); + + it("treats the same id as new again after the TTL lapses", () => { + const c = new PacketCoalescer(1000); + c.ingest({ id: 9, from: 10, sender: "aaaaaaaa" }, T0); + const arc = c.ingest({ id: 9, from: 10, sender: "aaaaaaaa" }, T0 + 1001); + expect(arc?.isNewTransmission).toBe(true); + }); + + it("evicts oldest beyond maxKeys", () => { + const c = new PacketCoalescer(60_000, 2); + c.ingest({ id: 1, from: 10, sender: "aaaaaaaa" }, T0); + c.ingest({ id: 2, from: 10, sender: "aaaaaaaa" }, T0); + c.ingest({ id: 3, from: 10, sender: "aaaaaaaa" }, T0); // evicts id:1 + expect(c.ingest({ id: 1, from: 10, sender: "aaaaaaaa" }, T0)?.isNewTransmission).toBe(true); + }); +}); diff --git a/frontend/src/pages/map/packetCoalescer.ts b/frontend/src/pages/map/packetCoalescer.ts new file mode 100644 index 00000000..244564bb --- /dev/null +++ b/frontend/src/pages/map/packetCoalescer.ts @@ -0,0 +1,71 @@ +import { normalizeNodeId8 } from "../../utils/normalizeNodeId8"; + +const BROADCAST = "ffffffff"; + +/** Raw `packet` SSE payload (clean_msg + mqtt_row_id). `from`/`to` are unnormalized. */ +export type RawPacket = { + id?: number | string; // mesh packet id (shared across gateway uplinks) + from?: number | string; + to?: number | string; + sender?: string; + type?: string; + rssi?: number; + snr?: number; + timestamp?: number | string; +}; + +export type PacketArc = { + fromId: string; // 8-char hex transmitter + senderId: string; // 8-char hex gateway that heard it + type?: string; + rssi?: number; + snr?: number; + /** First sighting of this transmission → also spawn the origin pulse. */ + isNewTransmission: boolean; +}; + +/** Dedups per-gateway uplinks of one transmission (same mesh `id`) into a fan-in: + * one origin pulse + one arc per gateway. Pure; caller owns timing/positions. */ +export class PacketCoalescer { + private seen = new Map; exp: number }>(); + + constructor( + private ttlMs = 45_000, + private maxKeys = 4_000, + ) {} + + /** The arc to animate, or null for a duplicate / unusable packet. */ + ingest(p: RawPacket, nowMs: number): PacketArc | null { + const fromId = normalizeNodeId8(p.from); + const senderId = normalizeNodeId8(p.sender); + if (!fromId || !senderId) return null; + if (fromId === BROADCAST || senderId === BROADCAST) return null; + + const key = p.id != null ? `id:${p.id}` : `syn:${fromId}:${p.timestamp ?? ""}:${p.type ?? ""}`; + const base = { fromId, senderId, type: p.type, rssi: num(p.rssi), snr: num(p.snr) }; + + const entry = this.seen.get(key); + if (!entry || entry.exp <= nowMs) { + if (entry) this.seen.delete(key); // refresh insertion order for FIFO eviction + this.seen.set(key, { senders: new Set([senderId]), exp: nowMs + this.ttlMs }); + this.evict(); + return { ...base, isNewTransmission: true }; + } + if (entry.senders.has(senderId)) return null; // same gateway, same transmission + entry.senders.add(senderId); + entry.exp = nowMs + this.ttlMs; + return { ...base, isNewTransmission: false }; + } + + private evict(): void { + while (this.seen.size > this.maxKeys) { + const oldest = this.seen.keys().next().value; + if (oldest === undefined) break; + this.seen.delete(oldest); + } + } +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} diff --git a/frontend/src/pages/map/packetColors.ts b/frontend/src/pages/map/packetColors.ts new file mode 100644 index 00000000..74bbc2a6 --- /dev/null +++ b/frontend/src/pages/map/packetColors.ts @@ -0,0 +1,19 @@ +/** Packet `type` → RGB 0..1, shared by the arc shader and (later) the legend. */ +export const PACKET_TYPE_COLORS: Record = { + text: [0.196, 0.941, 0.196], // green + text_binary: [0.4, 0.78, 0.4], + position: [0.23, 0.6, 0.98], // blue + telemetry: [0.95, 0.66, 0.13], // amber + nodeinfo: [0.66, 0.33, 0.97], // purple + neighborinfo: [0.08, 0.72, 0.65], // teal + traceroute: [0.93, 0.27, 0.27], // red + routing: [0.85, 0.82, 0.3], // yellow + mapreport: [0.35, 0.8, 0.92], // cyan +}; + +/** Unknown/unhandled portnums. */ +export const PACKET_TYPE_FALLBACK: [number, number, number] = [0.6, 0.62, 0.7]; + +export function packetColor(type: string | undefined): [number, number, number] { + return (type != null && PACKET_TYPE_COLORS[type]) || PACKET_TYPE_FALLBACK; +} diff --git a/frontend/src/pages/map/storage.ts b/frontend/src/pages/map/storage.ts index 03f4a1d9..aea14592 100644 --- a/frontend/src/pages/map/storage.ts +++ b/frontend/src/pages/map/storage.ts @@ -6,6 +6,8 @@ export const LS_KEYS = { osmBasemap: "meshinfo.map.osmBasemap", recentDays: "meshinfo.map.recentDays", clusterEnabled: "meshinfo.map.clusterEnabled", + /** Live packet-arc animation on/off (on by default, opt-out). */ + livePackets: "meshinfo.map.livePackets", settingsPanelOpen: "meshinfo.map.settingsPanelOpen", linkMode: "meshinfo.map.linkMode", myNodeId: "meshinfo.map.myNodeId", From 06d2c32cf193b76faba5b622e455f5b3aa489e7e Mon Sep 17 00:00:00 2001 From: SimmerV Date: Sun, 7 Jun 2026 18:04:00 -0700 Subject: [PATCH 2/8] feat: live traceroute multi-hop path tracer on the map --- frontend/src/pages/Map.tsx | 44 +++++++++++++++++++++++++ frontend/src/pages/map/activityLayer.ts | 34 +++++++++++++++---- mqtt.py | 19 ++++++++++- tests/test_mqtt_handlers.py | 17 ++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/Map.tsx b/frontend/src/pages/Map.tsx index 57b033df..a5f09757 100644 --- a/frontend/src/pages/Map.tsx +++ b/frontend/src/pages/Map.tsx @@ -14,6 +14,7 @@ import { buildMapStyle, ensureBuildings3D, ensureTerrain, isDarkBasemap, type Os import { useGetConfigQuery, useGetNodesQuery, useGetTraceroutesQuery } from "../slices/apiSlice"; import { NodeRole, roleTitles } from "../types"; import { convertNodeIdFromIntToHex } from "../utils/convertNodeId"; +import { normalizeNodeId8 } from "../utils/normalizeNodeId8"; import { prefersReducedMotion } from "../utils/reducedMotion"; import { ActivityLayer } from "./map/activityLayer"; import { ClusterDonutLayer } from "./map/clusterDonutLayer"; @@ -2422,6 +2423,7 @@ export function Map() { useLiveEvent("packet", (p) => { if (!livePacketsRef.current || prefersReducedMotion()) return; + if (p.type === "traceroute") return; // handled by the dedicated multi-hop tracer const coalescer = (coalescerRef.current ??= new PacketCoalescer()); const arc = coalescer.ingest(p, Date.now()); if (!arc) return; @@ -2429,6 +2431,48 @@ export function Map() { if (flushRafRef.current == null) flushRafRef.current = requestAnimationFrame(flushPacketArcs); }); + // Traceroute: animate the real ordered hop path [from, ...route, to], snapping + // each hop to its cluster (when clustering is on) and skipping hops with no + // known position. Drawn as one sequential comet. + useLiveEvent<{ from?: number | string; to?: number | string; route_ids?: (number | string)[] }>( + "traceroute", + (t) => { + if (!livePacketsRef.current || prefersReducedMotion()) return; + const layer = activityLayerRef.current; + if (!layer) return; + const liveNodes = nodesRef.current; + const map = mbMapRef.current; + const donut = clusterDonutLayerRef.current; + const clusters = clusterEnabledRef.current && map && donut ? donut.visibleClusters() : null; + const snap = (pos: [number, number]): [number, number] => { + if (!clusters || !map) return pos; + const p = map.project(pos); + let best: [number, number] | null = null; + let bestD = Infinity; + for (const c of clusters) { + const cp = map.project([c.lng, c.lat]); + const d = Math.hypot(cp.x - p.x, cp.y - p.y); + if (d <= c.r && d < bestD) { + bestD = d; + best = [c.lng, c.lat]; + } + } + return best ?? pos; + }; + const pts: [number, number][] = []; + for (const raw of [t.from, ...(t.route_ids ?? []), t.to]) { + const id = normalizeNodeId8(raw); + const pos = id ? liveNodes[id]?.map_position : undefined; + if (!pos) continue; // hop with unknown position — skip (honest gap) + const a = snap(pos); + const last = pts[pts.length - 1]; + if (!last || !samePoint(last, a)) pts.push(a); // collapse same-cluster hops + } + if (pts.length === 0) return; + layer.spawnPath(pts, packetColor("traceroute"), 0.9, performance.now()); + }, + ); + useEffect( () => () => { if (flushRafRef.current != null) cancelAnimationFrame(flushRafRef.current); diff --git a/frontend/src/pages/map/activityLayer.ts b/frontend/src/pages/map/activityLayer.ts index d1592a8e..b0fa894d 100644 --- a/frontend/src/pages/map/activityLayer.ts +++ b/frontend/src/pages/map/activityLayer.ts @@ -10,6 +10,7 @@ const STRIDE = FLOATS * 4; const MAX_POINTS = 16_384; const ARC_SAMPLES = 28; const ARC_MS = 1800; +const SEG_MS = 700; // per-hop comet duration for multi-hop (traceroute) paths const PULSE_MS = 750; const RIPPLE_MS = 750; const FRAME_MS = 1000 / 30; // keep-alive repaint cap (~30fps) @@ -229,9 +230,8 @@ export class ActivityLayer implements maplibregl.CustomLayerInterface { this.writeHead = start + count; } - /** from→sender comet + a gateway ripple on arrival. */ - spawnArc(from: LngLat, to: LngLat, color: RGB, weight: number, now: number): void { - if (!this.gl || !this.buffer) return; + /** Write one comet segment (28 samples) into the ring at [t0, t0+dur). */ + private writeArc(from: LngLat, to: LngLat, color: RGB, weight: number, t0: number, dur: number): void { const [lng0, lat0] = from; const [lng1, lat1] = to; const e0 = this.elevAt(lng0, lat0); @@ -258,8 +258,8 @@ export class ActivityLayer implements maplibregl.CustomLayerInterface { d[o++] = mercY(lat); d[o++] = mercZ(alt, lat); d[o++] = s; - d[o++] = now; - d[o++] = ARC_MS; + d[o++] = t0; + d[o++] = dur; d[o++] = color[0]; d[o++] = color[1]; d[o++] = color[2]; @@ -267,11 +267,33 @@ export class ActivityLayer implements maplibregl.CustomLayerInterface { d[o++] = weight; } this.writePoints(d, ARC_SAMPLES); - this.maxExpiry = Math.max(this.maxExpiry, now + ARC_MS); + this.maxExpiry = Math.max(this.maxExpiry, t0 + dur); + } + + /** from→sender comet + a gateway ripple on arrival. */ + spawnArc(from: LngLat, to: LngLat, color: RGB, weight: number, now: number): void { + if (!this.gl || !this.buffer) return; + this.writeArc(from, to, color, weight, now, ARC_MS); this.spawnRing(to, color, now + ARC_MS * 0.82, RIPPLE_MS, weight); this.scheduleNextFrame(); } + /** Sequential comet through a resolved multi-hop path (traceroute), hop by hop. */ + spawnPath(points: LngLat[], color: RGB, weight: number, now: number): void { + if (!this.gl || !this.buffer || points.length === 0) return; + if (points.length === 1) { + this.spawnRing(points[0], color, now, PULSE_MS, 1); + return; + } + this.spawnRing(points[0], color, now, PULSE_MS, 1); // origin pulse + for (let i = 0; i < points.length - 1; i++) { + const t0 = now + i * SEG_MS; + this.writeArc(points[i], points[i + 1], color, weight, t0, SEG_MS); + this.spawnRing(points[i + 1], color, t0 + SEG_MS * 0.9, RIPPLE_MS, 0.8); // ping as it lands + } + this.scheduleNextFrame(); + } + spawnPulse(at: LngLat, color: RGB, now: number): void { this.spawnRing(at, color, now, PULSE_MS, 1); } diff --git a/mqtt.py b/mqtt.py index 42613d73..201ef04f 100644 --- a/mqtt.py +++ b/mqtt.py @@ -668,4 +668,21 @@ async def handle_traceroute(self, msg): else: msg['route_ids'].append(r) - await self.data.pg_storage.write_traceroute(id, msg) \ No newline at end of file + await self.data.pg_storage.write_traceroute(id, msg) + + # Live push: resolved multi-hop path for the map's traceroute tracer. + if self.data.broadcaster.subscriber_count: + try: + self.data.broadcaster.publish( + "traceroute", + jsonable_encoder( + { + "from": id, + "to": msg.get("to"), + "route_ids": msg["route_ids"], + "id": msg.get("id"), + } + ), + ) + except Exception as e: + logger.debug("traceroute broadcast failed: %s", e) \ No newline at end of file diff --git a/tests/test_mqtt_handlers.py b/tests/test_mqtt_handlers.py index 24cf9a9e..3c527e65 100644 --- a/tests/test_mqtt_handlers.py +++ b/tests/test_mqtt_handlers.py @@ -367,6 +367,23 @@ def test_protobuf_int_route_normalized_to_hex(self): _, written = data.pg_storage.traceroute_writes[0] assert written["route_ids"] == ["67ea9400", "abcd1234"] + def test_publishes_traceroute_event(self): + mqtt, data = make_mqtt(nodes={ + "67ea9400": {"id": "67ea9400", "longname": "A"}, + "abcd1234": {"id": "abcd1234", "longname": "B"}, + }) + q = data.broadcaster.subscribe() + run(mqtt.handle_traceroute({ + "from": 0x67EA9400, + "to": 0xABCD1234, + "payload": {"route": [0x67EA9400]}, + })) + event_type, payload = q.get_nowait() + assert event_type == "traceroute" + assert payload["from"] == "67ea9400" + assert payload["to"] == "abcd1234" + assert payload["route_ids"] == ["67ea9400"] + def test_json_longname_route_resolved(self): mqtt, data = make_mqtt(nodes={ "67ea9400": {"id": "67ea9400", "longname": "Alpha"}, From e036e5a4bbb7e5c864a6e0451971ff5d2e146f7f Mon Sep 17 00:00:00 2001 From: SimmerV Date: Sun, 7 Jun 2026 18:12:00 -0700 Subject: [PATCH 3/8] fix(frontend): dedup multi-gateway traceroute copies into one path --- frontend/src/pages/Map.tsx | 104 +++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/frontend/src/pages/Map.tsx b/frontend/src/pages/Map.tsx index a5f09757..60370ccc 100644 --- a/frontend/src/pages/Map.tsx +++ b/frontend/src/pages/Map.tsx @@ -75,6 +75,10 @@ const MAX_ARCS_PER_FLUSH = 40; const samePoint = (a: [number, number], b: [number, number]) => a[0] === b[0] && a[1] === b[1]; +type TraceEv = { from?: number | string; to?: number | string; route_ids?: (number | string)[]; id?: number | string }; +// Wait this long for other gateways' copies of one traceroute before drawing the best. +const TRACEROUTE_DEBOUNCE_MS = 1200; + /** Map a reported signal to a 0..1 arc intensity — prefer SNR, fall back to RSSI. */ function packetWeight(rssi?: number, snr?: number): number { const clamp = (v: number) => Math.max(0.15, Math.min(1, v)); @@ -110,6 +114,8 @@ export function Map() { const coalescerRef = useRef(null); const pendingArcsRef = useRef([]); const flushRafRef = useRef(null); + // Per-mesh-id debounce of multi-gateway traceroute copies → one comet, longest route. + const tracerouteBufRef = useRef }> | null>(null); const mbHandlersBoundRef = useRef(false); const mbCurrentStyleUrlRef = useRef(null); const mbKeydownHandlerRef = useRef<((e: KeyboardEvent) => void) | null>(null); @@ -2431,51 +2437,71 @@ export function Map() { if (flushRafRef.current == null) flushRafRef.current = requestAnimationFrame(flushPacketArcs); }); - // Traceroute: animate the real ordered hop path [from, ...route, to], snapping - // each hop to its cluster (when clustering is on) and skipping hops with no - // known position. Drawn as one sequential comet. - useLiveEvent<{ from?: number | string; to?: number | string; route_ids?: (number | string)[] }>( - "traceroute", - (t) => { - if (!livePacketsRef.current || prefersReducedMotion()) return; - const layer = activityLayerRef.current; - if (!layer) return; - const liveNodes = nodesRef.current; - const map = mbMapRef.current; - const donut = clusterDonutLayerRef.current; - const clusters = clusterEnabledRef.current && map && donut ? donut.visibleClusters() : null; - const snap = (pos: [number, number]): [number, number] => { - if (!clusters || !map) return pos; - const p = map.project(pos); - let best: [number, number] | null = null; - let bestD = Infinity; - for (const c of clusters) { - const cp = map.project([c.lng, c.lat]); - const d = Math.hypot(cp.x - p.x, cp.y - p.y); - if (d <= c.r && d < bestD) { - bestD = d; - best = [c.lng, c.lat]; - } + // Resolve a traceroute's hops to positions and draw one sequential comet along + // [from, ...route, to], snapping each hop to its cluster and skipping hops with + // no known position. + const animateTraceroute = useCallback((t: TraceEv) => { + const layer = activityLayerRef.current; + if (!layer) return; + const liveNodes = nodesRef.current; + const map = mbMapRef.current; + const donut = clusterDonutLayerRef.current; + const clusters = clusterEnabledRef.current && map && donut ? donut.visibleClusters() : null; + const snap = (pos: [number, number]): [number, number] => { + if (!clusters || !map) return pos; + const p = map.project(pos); + let best: [number, number] | null = null; + let bestD = Infinity; + for (const c of clusters) { + const cp = map.project([c.lng, c.lat]); + const d = Math.hypot(cp.x - p.x, cp.y - p.y); + if (d <= c.r && d < bestD) { + bestD = d; + best = [c.lng, c.lat]; } - return best ?? pos; - }; - const pts: [number, number][] = []; - for (const raw of [t.from, ...(t.route_ids ?? []), t.to]) { - const id = normalizeNodeId8(raw); - const pos = id ? liveNodes[id]?.map_position : undefined; - if (!pos) continue; // hop with unknown position — skip (honest gap) - const a = snap(pos); - const last = pts[pts.length - 1]; - if (!last || !samePoint(last, a)) pts.push(a); // collapse same-cluster hops } - if (pts.length === 0) return; - layer.spawnPath(pts, packetColor("traceroute"), 0.9, performance.now()); - }, - ); + return best ?? pos; + }; + const pts: [number, number][] = []; + for (const raw of [t.from, ...(t.route_ids ?? []), t.to]) { + const id = normalizeNodeId8(raw); + const pos = id ? liveNodes[id]?.map_position : undefined; + if (!pos) continue; // hop with unknown position — skip (honest gap) + const a = snap(pos); + const last = pts[pts.length - 1]; + if (!last || !samePoint(last, a)) pts.push(a); // collapse same-cluster hops + } + if (pts.length === 0) return; + layer.spawnPath(pts, packetColor("traceroute"), 0.9, performance.now()); + }, []); + + // The same traceroute is uploaded by many gateways with divergent recorded + // routes; debounce by mesh id and draw only the single most complete path. + useLiveEvent("traceroute", (t) => { + if (!livePacketsRef.current || prefersReducedMotion()) return; + const buf = (tracerouteBufRef.current ??= new globalThis.Map()); + const key = t.id != null ? `id:${t.id}` : `ft:${t.from}:${t.to}`; + const existing = buf.get(key); + if (existing) { + if ((t.route_ids?.length ?? 0) > (existing.ev.route_ids?.length ?? 0)) existing.ev = t; + return; + } + const timer = setTimeout(() => { + const entry = buf.get(key); + buf.delete(key); + if (entry) animateTraceroute(entry.ev); + }, TRACEROUTE_DEBOUNCE_MS); + buf.set(key, { ev: t, timer }); + }); useEffect( () => () => { if (flushRafRef.current != null) cancelAnimationFrame(flushRafRef.current); + const buf = tracerouteBufRef.current; + if (buf) { + for (const { timer } of buf.values()) clearTimeout(timer); + buf.clear(); + } }, [], ); From 67af78d6bc13177662b8ea3d1f45353da6ff7325 Mon Sep 17 00:00:00 2001 From: SimmerV Date: Sun, 7 Jun 2026 18:20:51 -0700 Subject: [PATCH 4/8] feat(frontend): refresh map legend for live packets, node recency, and traceroute --- frontend/src/pages/map/MapLegend.tsx | 51 ++++++++++++++++++++------ frontend/src/pages/map/packetColors.ts | 20 +++++++++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/map/MapLegend.tsx b/frontend/src/pages/map/MapLegend.tsx index 697ab989..7823a843 100644 --- a/frontend/src/pages/map/MapLegend.tsx +++ b/frontend/src/pages/map/MapLegend.tsx @@ -1,3 +1,4 @@ +import { PACKET_TYPE_LABELS, packetColorCss } from "./packetColors"; import type { LinkMode } from "./types"; export function MapLegend({ @@ -25,6 +26,7 @@ export function MapLegend({ Legend
+ {/* Nodes */}
Online node @@ -39,6 +41,18 @@ export function MapLegend({
Selected node
+
+ + Brightness = recency (nodes & links) +
- {/* Link color = SNR (quality). Kind is conveyed by line style below. */} + {/* Links — color = SNR (quality); kind = line style */}
SNR unknown
-
Traceroute (inferred) + Traceroute path (on select)
+ +
+ + {/* Live packets (SSE) */} +
Live packets
- Recency (recent → stale) + Packet → gateway that heard it +
+
+ {PACKET_TYPE_LABELS.map(({ type, label }) => ( +
+ + {label} +
+ ))} +
+
+ Traceroutes animate the full multi-hop path.
diff --git a/frontend/src/pages/map/packetColors.ts b/frontend/src/pages/map/packetColors.ts index 74bbc2a6..29c96f1b 100644 --- a/frontend/src/pages/map/packetColors.ts +++ b/frontend/src/pages/map/packetColors.ts @@ -1,4 +1,4 @@ -/** Packet `type` → RGB 0..1, shared by the arc shader and (later) the legend. */ +/** Packet `type` → RGB 0..1, shared by the arc shader and the legend. */ export const PACKET_TYPE_COLORS: Record = { text: [0.196, 0.941, 0.196], // green text_binary: [0.4, 0.78, 0.4], @@ -17,3 +17,21 @@ export const PACKET_TYPE_FALLBACK: [number, number, number] = [0.6, 0.62, 0.7]; export function packetColor(type: string | undefined): [number, number, number] { return (type != null && PACKET_TYPE_COLORS[type]) || PACKET_TYPE_FALLBACK; } + +/** Same color as the arc shader, as a CSS string for the legend swatches. */ +export function packetColorCss(type: string | undefined): string { + const [r, g, b] = packetColor(type); + return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`; +} + +/** Packet types shown in the legend key (order = legend order). */ +export const PACKET_TYPE_LABELS: { type: string; label: string }[] = [ + { type: "text", label: "Text" }, + { type: "position", label: "Position" }, + { type: "telemetry", label: "Telemetry" }, + { type: "nodeinfo", label: "Node info" }, + { type: "neighborinfo", label: "Neighbor" }, + { type: "traceroute", label: "Traceroute" }, + { type: "routing", label: "Routing" }, + { type: "mapreport", label: "Map report" }, +]; From 9fb4281792a189635f4ae2179ce41a44eac4b008 Mon Sep 17 00:00:00 2001 From: SimmerV Date: Sun, 7 Jun 2026 19:38:41 -0700 Subject: [PATCH 5/8] feat(frontend): node activity sparkline, animations toggle, backfill node sparkline --- frontend/src/pages/Map.tsx | 12 +++- frontend/src/pages/map/MapDetailsPanel.tsx | 77 +++++++++++++++++++++ frontend/src/pages/map/MapSettingsPanel.tsx | 30 ++++++++ frontend/src/pages/map/activityLayer.ts | 18 ++--- frontend/src/pages/map/clusterDonutLayer.ts | 9 ++- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/Map.tsx b/frontend/src/pages/Map.tsx index 60370ccc..7176fa3a 100644 --- a/frontend/src/pages/Map.tsx +++ b/frontend/src/pages/Map.tsx @@ -362,7 +362,10 @@ export function Map() { useEffect(() => { setDetailsDataRef.current = setDetailsData; }, [setDetailsData]); useEffect(() => { recentDaysRef.current = recentDays; }, [recentDays]); useEffect(() => { clusterEnabledRef.current = clusterEnabled; }, [clusterEnabled]); - useEffect(() => { livePacketsRef.current = livePackets; }, [livePackets]); + useEffect(() => { + livePacketsRef.current = livePackets; + clusterDonutLayerRef.current?.setAnimationsEnabled(livePackets); + }, [livePackets]); useEffect(() => { linkModeRef.current = linkMode; }, [linkMode]); useEffect(() => { roleFilterRef.current = roleFilter; }, [roleFilter]); useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]); @@ -1317,6 +1320,7 @@ export function Map() { const donutLayer = new ClusterDonutLayer(); map.addLayer(donutLayer); clusterDonutLayerRef.current = donutLayer; + donutLayer.setAnimationsEnabled(livePacketsRef.current); if (activeToolRef.current != null && toolStepRef.current === "result") donutLayer.setAlpha(0.25); } @@ -2599,6 +2603,8 @@ export function Map() { setTerrain3D={setTerrain3D} buildings3D={buildings3D} setBuildings3D={setBuildings3D} + livePackets={livePackets} + setLivePackets={setLivePackets} onExport={handleExport} hidden={!!detailsData || activeTool != null} recentDays={recentDays} @@ -2666,10 +2672,10 @@ export function Map() { type="button" onClick={() => setLivePackets((v) => !v)} className="absolute bottom-3 left-3 z-30 flex items-center gap-2 rounded-xl border border-white/10 bg-gray-900/80 px-3 py-1.5 text-xs font-medium shadow-2xl backdrop-blur-xl transition hover:bg-gray-900/90" - title={livePackets ? "Live packet arcs on — click to turn off" : "Live packet arcs off — click to turn on"} + title={livePackets ? "Live map animations on — click to turn off" : "Live map animations off — click to turn on"} > - Live packets + Animations {/* Live terrain elevation under the cursor — helps sanity-check coverage diff --git a/frontend/src/pages/map/MapDetailsPanel.tsx b/frontend/src/pages/map/MapDetailsPanel.tsx index 980fd67f..c5965bf3 100644 --- a/frontend/src/pages/map/MapDetailsPanel.tsx +++ b/frontend/src/pages/map/MapDetailsPanel.tsx @@ -1,8 +1,12 @@ import { useEffect, useRef, useState } from "react"; +import { useLiveEvent } from "../../hooks/useLiveEvent"; +import { useGetNodePacketsQuery } from "../../slices/apiSlice"; import { HardwareModel, type NodeRole,roleTitles } from "../../types"; import { getElsewhereLinks, resolveElsewhereUrl } from "../../utils/elsewhereLinks"; +import { normalizeNodeId8 } from "../../utils/normalizeNodeId8"; import { normNodeId } from "./linkFeatures"; +import { Sparkline } from "./Sparkline"; import { TelemetrySection } from "./TelemetrySection"; import type { IMapNode, NodeDetailsData } from "./types"; import { useBottomSheetGesture } from "./useBottomSheet"; @@ -186,6 +190,77 @@ function CollapsibleSection({ ); } +const ACTIVITY_WINDOW_MS = 15 * 60 * 1000; +const ACTIVITY_BINS = 30; + +/** Epoch ms from a packet timestamp (number in s or ms, or an ISO string). */ +function packetTimeMs(ts: unknown): number | null { + if (typeof ts === "number" && Number.isFinite(ts)) return ts > 1e12 ? ts : ts * 1000; + if (typeof ts === "string") { + const ms = Date.parse(ts); + return Number.isFinite(ms) ? ms : null; + } + return null; +} + +/** Sparkline of packets involving this node over the last 15 min — seeded from + * recent history, then kept current from the SSE packet stream. */ +function RecentActivitySparkline({ nodeId }: { nodeId: string }) { + const { data } = useGetNodePacketsQuery({ nodeId, limit: 200 }); + const liveRef = useRef([]); + const [bins, setBins] = useState(() => new Array(ACTIVITY_BINS).fill(0)); + + useEffect(() => { + liveRef.current = []; + }, [nodeId]); + + useLiveEvent<{ from?: number | string; to?: number | string; timestamp?: number | string }>( + "packet", + (p) => { + if (normalizeNodeId8(p.from) === nodeId || normalizeNodeId8(p.to) === nodeId) { + liveRef.current.push(packetTimeMs(p.timestamp) ?? Date.now()); + } + }, + ); + + useEffect(() => { + const recompute = () => { + const cutoff = Date.now() - ACTIVITY_WINDOW_MS; + liveRef.current = liveRef.current.filter((t) => t >= cutoff); + const stamps = [...liveRef.current]; + for (const pkt of data?.packets ?? []) { + if (normalizeNodeId8(pkt.from) !== nodeId && normalizeNodeId8(pkt.to) !== nodeId) continue; + const t = packetTimeMs(pkt.timestamp); + if (t != null && t >= cutoff) stamps.push(t); + } + const b = new Array(ACTIVITY_BINS).fill(0); + for (const t of stamps) { + b[Math.min(ACTIVITY_BINS - 1, Math.floor(((t - cutoff) / ACTIVITY_WINDOW_MS) * ACTIVITY_BINS))] += 1; + } + setBins(b); + }; + recompute(); + const id = setInterval(recompute, 1500); + return () => clearInterval(id); + }, [data, nodeId]); + + const total = bins.reduce((a, b) => a + b, 0); + + return ( +
+
Recent activity (15 min)
+ {total === 0 ? ( +
No packets in the last 15 min
+ ) : ( +
+ + {total} pkt +
+ )} +
+ ); +} + export function MapDetailsPanel({ data, onClose, @@ -413,6 +488,8 @@ export function MapDetailsPanel({ )}
+ +