diff --git a/config.example.json b/config.example.json
index a2b6a759..4395e01d 100644
--- a/config.example.json
+++ b/config.example.json
@@ -76,6 +76,10 @@
"invalidationDebounce": 30,
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
},
+ "liveMap": {
+ "propagationBufferMs": 5000,
+ "_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
+ },
"packetStore": {
"maxMemoryMB": 1024,
"estimatedPacketBytes": 450,
diff --git a/public/index.html b/public/index.html
index 966f71a8..23231520 100644
--- a/public/index.html
+++ b/public/index.html
@@ -79,7 +79,7 @@
-
+
@@ -88,7 +88,7 @@
-
+
diff --git a/public/live.js b/public/live.js
index eab4859f..82427e68 100644
--- a/public/live.js
+++ b/public/live.js
@@ -11,6 +11,8 @@
let audioCtx = null;
let soundEnabled = false;
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
+ let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true';
+ const propagationBuffer = new Map(); // hash -> {timer, packets[]}
let _onResize = null;
let _navCleanup = null;
let _timelineRefreshInterval = null;
@@ -437,7 +439,21 @@
}
if (VCR.mode === 'LIVE') {
- animatePacket(pkt);
+ if (realisticPropagation && pkt.hash) {
+ const hash = pkt.hash;
+ if (propagationBuffer.has(hash)) {
+ propagationBuffer.get(hash).packets.push(pkt);
+ } else {
+ const entry = { packets: [pkt], timer: setTimeout(() => {
+ const buffered = propagationBuffer.get(hash);
+ propagationBuffer.delete(hash);
+ if (buffered) animateRealisticPropagation(buffered.packets);
+ }, PROPAGATION_BUFFER_MS) };
+ propagationBuffer.set(hash, entry);
+ }
+ } else {
+ animatePacket(pkt);
+ }
updateTimeline();
} else if (VCR.mode === 'PAUSED') {
VCR.missedCount++;
@@ -592,6 +608,8 @@
Overlay a density heat map on the mesh nodes
Show interpolated ghost markers for unknown hops
+
+ Buffer packets by hash and animate all paths simultaneously
@@ -708,6 +726,13 @@
localStorage.setItem('live-ghost-hops', showGhostHops);
});
+ const realisticToggle = document.getElementById('liveRealisticToggle');
+ realisticToggle.checked = realisticPropagation;
+ realisticToggle.addEventListener('change', (e) => {
+ realisticPropagation = e.target.checked;
+ localStorage.setItem('live-realistic-propagation', realisticPropagation);
+ });
+
// Feed show/hide
const feedEl = document.getElementById('liveFeed');
// Keyboard support for feed items (event delegation)
@@ -1183,6 +1208,91 @@
animatePath(hopPositions, typeName, color);
}
+ function animateRealisticPropagation(packets) {
+ if (!packets.length) return;
+ const first = packets[0];
+ const decoded = first.decoded || {};
+ const header = decoded.header || {};
+ const typeName = header.payloadTypeName || 'UNKNOWN';
+ const color = TYPE_COLORS[typeName] || '#6b7280';
+ const icon = PAYLOAD_ICONS[typeName] || '📦';
+ const payload = decoded.payload || {};
+
+ packetCount += packets.length;
+ pktTimestamps.push(Date.now());
+ const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
+
+ playSound(typeName);
+
+ // Ensure ADVERT nodes appear
+ for (const pkt of packets) {
+ const d = pkt.decoded || {};
+ const h = d.header || {};
+ const p = d.payload || {};
+ if (h.payloadTypeName === 'ADVERT' && p.pubKey) {
+ const key = p.pubKey;
+ if (!nodeMarkers[key] && p.lat != null && p.lon != null && !(p.lat === 0 && p.lon === 0)) {
+ const n = { public_key: key, name: p.name || key.slice(0,8), role: p.role || 'unknown', lat: p.lat, lon: p.lon };
+ nodeData[key] = n;
+ addNodeMarker(n);
+ }
+ }
+ }
+ const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
+
+ // Resolve all unique paths
+ const allPaths = [];
+ const seenPathKeys = new Set();
+ const observers = new Set();
+ for (const pkt of packets) {
+ const d = pkt.decoded || {};
+ const p = d.payload || {};
+ const hops = d.path?.hops || [];
+ if (pkt.observer) observers.add(pkt.observer);
+ const pathKey = hops.join(',');
+ if (seenPathKeys.has(pathKey)) continue;
+ seenPathKeys.add(pathKey);
+ const hopPositions = resolveHopPositions(hops, p);
+ if (hopPositions.length >= 2) allPaths.push(hopPositions);
+ }
+
+ // Consolidated feed item
+ const hops0 = decoded.path?.hops || [];
+ const text = payload.text || payload.name || '';
+ const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
+ const feed = document.getElementById('liveFeed');
+ if (feed) {
+ const item = document.createElement('div');
+ item.className = 'live-feed-item live-feed-enter';
+ item.setAttribute('tabindex', '0');
+ item.setAttribute('role', 'button');
+ item.style.cursor = 'pointer';
+ item.innerHTML = `
+ ${icon}
+ ${typeName}
+ ${allPaths.length}⇢ ${observers.size}👁
+ ${escapeHtml(preview)}
+ ${new Date(first._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}
+ `;
+ item.addEventListener('click', () => showFeedCard(item, first, color));
+ feed.prepend(item);
+ requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); });
+ while (feed.children.length > 25) feed.removeChild(feed.lastChild);
+ }
+
+ if (allPaths.length === 0) {
+ // Single hop or unresolvable — just pulse origin if possible
+ const hp0 = resolveHopPositions(decoded.path?.hops || [], payload);
+ if (hp0.length >= 1) pulseNode(hp0[0].key, hp0[0].pos, typeName);
+ return;
+ }
+
+ // Animate all paths simultaneously
+ for (const hopPositions of allPaths) {
+ animatePath(hopPositions, typeName, color);
+ }
+ }
+
function resolveHopPositions(hops, payload) {
const known = Object.values(nodeData);
diff --git a/public/roles.js b/public/roles.js
index deb56d61..9b9fb8b1 100644
--- a/public/roles.js
+++ b/public/roles.js
@@ -87,6 +87,9 @@
// ─── WebSocket reconnect delay (ms) ───
window.WS_RECONNECT_MS = 3000;
+ // ─── Propagation buffer (ms) for realistic mode ───
+ window.PROPAGATION_BUFFER_MS = 5000;
+
// ─── Cache invalidation debounce (ms) ───
window.CACHE_INVALIDATE_MS = 5000;
@@ -119,6 +122,7 @@
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
+ if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
// Sync ROLE_STYLE colors with ROLE_COLORS
for (var role in ROLE_STYLE) {
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
diff --git a/server.js b/server.js
index 6c0b8efa..e60daf41 100644
--- a/server.js
+++ b/server.js
@@ -238,7 +238,8 @@ app.get('/api/config/client', (req, res) => {
perfSlowMs: config.perfSlowMs || null,
wsReconnectMs: config.wsReconnectMs || null,
cacheInvalidateMs: config.cacheInvalidateMs || null,
- externalUrls: config.externalUrls || null
+ externalUrls: config.externalUrls || null,
+ propagationBufferMs: (config.liveMap || {}).propagationBufferMs || 5000
});
});