diff --git a/frontend/src/pages/Map.tsx b/frontend/src/pages/Map.tsx index b51c8fab..b4041613 100644 --- a/frontend/src/pages/Map.tsx +++ b/frontend/src/pages/Map.tsx @@ -8,11 +8,15 @@ 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 { normalizeNodeId8 } from "../utils/normalizeNodeId8"; +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 +33,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 +70,23 @@ 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]; + +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)); + 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 +109,13 @@ 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); + // 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); @@ -137,6 +167,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 +226,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 +339,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 +362,10 @@ export function Map() { useEffect(() => { setDetailsDataRef.current = setDetailsData; }, [setDetailsData]); useEffect(() => { recentDaysRef.current = recentDays; }, [recentDays]); useEffect(() => { clusterEnabledRef.current = clusterEnabled; }, [clusterEnabled]); + useEffect(() => { + livePacketsRef.current = livePackets; + clusterDonutLayerRef.current?.setAnimationsEnabled(livePackets); + }, [livePackets]); useEffect(() => { linkModeRef.current = linkMode; }, [linkMode]); useEffect(() => { roleFilterRef.current = roleFilter; }, [roleFilter]); useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]); @@ -1283,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); } @@ -1429,6 +1467,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 +2379,139 @@ 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; + }; + + layer.beginBatch(); // coalesce this flush into one GPU upload + 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 + } + } + layer.endBatch(); + }, []); + + 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; + pendingArcsRef.current.push(arc); + if (flushRafRef.current == null) flushRafRef.current = requestAnimationFrame(flushPacketArcs); + }); + + // 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()); + }, []); + + // 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(); + } + }, + [], + ); + // React to linkMode / myNodeId / nodes changes for persistent links useEffect(() => { const map = mbMapRef.current; @@ -2427,6 +2605,8 @@ export function Map() { setTerrain3D={setTerrain3D} buildings3D={buildings3D} setBuildings3D={setBuildings3D} + livePackets={livePackets} + setLivePackets={setLivePackets} onExport={handleExport} hidden={!!detailsData || activeTool != null} recentDays={recentDays} @@ -2490,6 +2670,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/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({ )} + +
+ {/* 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/MapSettingsPanel.tsx b/frontend/src/pages/map/MapSettingsPanel.tsx index 0a5fa86a..8431389f 100644 --- a/frontend/src/pages/map/MapSettingsPanel.tsx +++ b/frontend/src/pages/map/MapSettingsPanel.tsx @@ -217,6 +217,8 @@ export function MapSettingsPanel({ setTerrain3D, buildings3D, setBuildings3D, + livePackets, + setLivePackets, onExport, hidden = false, @@ -260,6 +262,8 @@ export function MapSettingsPanel({ setTerrain3D: Dispatch>; buildings3D: boolean; setBuildings3D: Dispatch>; + livePackets: boolean; + setLivePackets: Dispatch>; onExport?: () => void; hidden?: boolean; @@ -547,6 +551,32 @@ export function MapSettingsPanel({ )} +
toggleSection("animations")} + title="Animations" + subtitle={livePackets ? "On" : "Off"} + > +
+
+ +

+ Packet arcs, traceroute paths, and cluster transitions +

+
+ setLivePackets(e.target.checked)} + className="h-4 w-4 rounded-sm border-gray-600 bg-gray-700 text-cyan-500 focus:ring-cyan-500" + aria-label="Toggle live map animations" + /> +
+
+
toggleSection("mynode")} title="My Node" subtitle={myNodeLabel || "Not set"}>
diff --git a/frontend/src/pages/map/activityLayer.ts b/frontend/src/pages/map/activityLayer.ts new file mode 100644 index 00000000..c69cec63 --- /dev/null +++ b/frontend/src/pages/map/activityLayer.ts @@ -0,0 +1,428 @@ +/** 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 = 36; +const SPEED_PX_PER_MS = 0.5; // constant comet speed (~500 css-px/s) at any zoom +const MIN_ARC_MS = 450; // floor so short hops aren't a blink +const MAX_ARC_MS = 3000; // ceiling so cross-screen arcs aren't tedious +const PULSE_MS = 750; +const RIPPLE_MS = 750; +const FRAME_MS = 1000 / 30; // keep-alive repaint cap (~30fps, vsync-aligned via rAF) +const MAX_BATCH_POINTS = 2048; // staging capacity for one coalesced flush + +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) { + // comet: bright head at phase, medium fading trail behind + float w = mix(0.6, 1.0, a_weight); + float globalFade = 1.0 - smoothstep(0.85, 1.0, phase); + float d = phase - a_s; + float vis = (d >= 0.0 && d <= 0.30) ? (1.0 - d / 0.30) : 0.0; + size = (2.5 + 5.0 * vis) * w * 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 repaintHandle: number | null = null; + private lastRenderTs = 0; // performance.now() of the last actual render (fps cap) + private liveHigh = 0; // highest written slot + 1; bounds the draw range + + private scratchArc = new Float32Array(ARC_SAMPLES * FLOATS); + private scratchRing = new Float32Array(FLOATS); + private stage = new Float32Array(MAX_BATCH_POINTS * FLOATS); // accumulates one flush + private stageCount = 0; + private batching = false; + + 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.repaintHandle != null) cancelAnimationFrame(this.repaintHandle); + this.repaintHandle = 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(); + } + + /** Keep-alive repaint, vsync-aligned via rAF, capped to ~30fps by frame-skip. + * Uniform spacing avoids the jitter a setTimeout clock produced. */ + private scheduleNextFrame(): void { + if (this.repaintHandle != null || !this.map) return; + this.repaintHandle = requestAnimationFrame((ts) => { + this.repaintHandle = null; + if (ts - this.lastRenderTs < FRAME_MS - 1) { + this.scheduleNextFrame(); // too soon — wait for the next vsync, don't render yet + return; + } + this.map?.triggerRepaint(); + }); + } + + 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.liveHigh = MAX_POINTS; // wrapped: live primitives may occupy any slot + } + 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; + if (this.liveHigh < this.writeHead) this.liveHigh = this.writeHead; + } + + /** Stage points during a batch (one upload at endBatch); write through otherwise. */ + private emit(data: Float32Array, count: number): void { + if (!this.batching) { + this.writePoints(data, count); + return; + } + if ((this.stageCount + count) * FLOATS > this.stage.length) { + if (this.stageCount > 0) { + this.writePoints(this.stage, this.stageCount); // stage full → flush, keep batching + this.stageCount = 0; + } + if (count * FLOATS > this.stage.length) { + this.writePoints(data, count); // single item bigger than stage (shouldn't happen) + return; + } + } + this.stage.set(data.subarray(0, count * FLOATS), this.stageCount * FLOATS); + this.stageCount += count; + } + + /** Coalesce a burst of spawns into one GPU upload (beginBatch … endBatch). */ + beginBatch(): void { + this.batching = true; + this.stageCount = 0; + } + + endBatch(): void { + if (this.stageCount > 0) { + this.writePoints(this.stage, this.stageCount); + this.stageCount = 0; + } + this.batching = false; + this.scheduleNextFrame(); + } + + /** Write one comet segment 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); + 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++] = t0; + d[o++] = dur; + d[o++] = color[0]; + d[o++] = color[1]; + d[o++] = color[2]; + d[o++] = 0; + d[o++] = weight; + } + this.emit(d, ARC_SAMPLES); + this.maxExpiry = Math.max(this.maxExpiry, t0 + dur); + } + + /** Comet duration from on-screen distance → constant travel speed at any zoom. */ + private arcDur(from: LngLat, to: LngLat): number { + const map = this.map; + if (!map) return MIN_ARC_MS; + const p0 = map.project(from); + const p1 = map.project(to); + const px = Math.hypot(p1.x - p0.x, p1.y - p0.y); + return Math.min(MAX_ARC_MS, Math.max(MIN_ARC_MS, px / SPEED_PX_PER_MS)); + } + + /** 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 dur = this.arcDur(from, to); + this.writeArc(from, to, color, weight, now, dur); + this.spawnRing(to, color, now + dur * 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 + let t0 = now; + for (let i = 0; i < points.length - 1; i++) { + const dur = this.arcDur(points[i], points[i + 1]); + this.writeArc(points[i], points[i + 1], color, weight, t0, dur); + this.spawnRing(points[i + 1], color, t0 + dur * 0.9, RIPPLE_MS, 0.8); // ping as it lands + t0 += dur; + } + 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.emit(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) + this.lastRenderTs = now; + + 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_MINUS_SRC_ALPHA); + gl.drawArrays(gl.POINTS, 0, this.liveHigh); + + 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..288d8f0c 100644 --- a/frontend/src/pages/map/clusterDonutLayer.ts +++ b/frontend/src/pages/map/clusterDonutLayer.ts @@ -148,6 +148,12 @@ 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; + /** When false, ratio changes snap instead of tweening (animations toggle off). */ + private animationsEnabled = true; private onMoveend: (() => void) | null = null; private onIdle: (() => void) | null = null; @@ -204,6 +210,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 +238,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 +249,16 @@ 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 })); + } + + /** Gate the ratio tween with the global animations toggle (snap when off). */ + setAnimationsEnabled(enabled: boolean): void { + this.animationsEnabled = enabled; + } + private rebuild(): void { const map = this.map; if (!map) return; @@ -282,7 +300,7 @@ export class ClusterDonutLayer implements maplibregl.CustomLayerInterface { /** Start a tween for each cluster whose ratio target changed; prune the rest. */ private reconcileTweens(next: { ratio: number; key: string }[]): void { const now = performance.now(); - const reduce = prefersReducedMotion(); + const reduce = prefersReducedMotion() || !this.animationsEnabled; const live = new Set(); for (const c of next) { live.add(c.key); @@ -328,7 +346,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 +373,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 +381,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..29c96f1b --- /dev/null +++ b/frontend/src/pages/map/packetColors.ts @@ -0,0 +1,37 @@ +/** 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], + 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; +} + +/** 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" }, +]; 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", 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"},