refactor: unify live page packet rendering into renderPacketTree()

Major refactor of live.js data flow:

- Replaced animatePacket() and animateRealisticPropagation() with
  single renderPacketTree(packets, isReplay) function
- All paths use the same function: WS arrival, VCR replay, DB load,
  feed card replay button
- VCR fetches use expand=observations to get full observation data
- expandToBufferEntries() extracts per-observer paths from observations
- startReplay() pre-aggregates VCR buffer by hash before playback
- Feed dedup accumulates observation packets for full tree replay
- Longest path shown in feed (scans all observations, not just first)
- Replay button uses full observation set for starburst animation

Server changes:
- WS broadcast includes path_json per observation
- packet-store insert() uses longest path for display (was earliest)

DB changes:
- Removed seed() function and synthetic test data

Not pushed to prod — local testing only.
This commit is contained in:
you committed 2026-03-25 06:02:48 +00:00
1 parent 78e56f064f
commit 63f2f7c995
5 files changed
+379 -293

No files matched your search

+1 -40
View File
@@ -360,47 +360,8 @@ function getStats() {
};
}
function seed() {
if (stmts.countPackets.get().count > 0) return false;
const now = new Date().toISOString();
const rawHex = '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172';
upsertObserver({ id: 'obs-seed-001', name: 'Seed Observer', iata: 'UNK', last_seen: now, first_seen: now });
insertTransmission({
raw_hex: rawHex,
timestamp: now,
observer_id: 'obs-seed-001',
observer_name: 'Seed Observer',
direction: 'rx',
snr: 10.5,
rssi: -85,
score: 42,
hash: 'seed-test-hash',
route_type: 1,
payload_type: 4,
payload_version: 1,
path_json: JSON.stringify(['A1B2', 'C3D4']),
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Test Repeater', role: 'repeater', lat: 0, lon: 0 }),
});
upsertNode({
public_key: 'seed-test-pubkey',
name: 'Test Repeater',
role: 'repeater',
lat: 0,
lon: 0,
last_seen: now,
first_seen: now,
});
return true;
}
// --- Run directly ---
if (require.main === module) {
const seeded = seed();
console.log(seeded ? 'Database seeded with test data.' : 'Database already has data, skipping seed.');
console.log('Stats:', getStats());
}
@@ -631,4 +592,4 @@ function getNodeAnalytics(pubkey, days) {
};
}
module.exports = { db, insertTransmission, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
module.exports = { db, insertTransmission, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, searchNodes, getNodeHealth, getNodeAnalytics };
+28 -10
View File
@@ -156,19 +156,24 @@ class PacketStore {
}
}
// Post-load: set each transmission's observer/path to the EARLIEST observation
// Post-load: set each transmission's display path to the LONGEST observation path
// (most representative of mesh topology — short paths are just nearby observers)
for (const tx of this.packets) {
if (tx.observations.length > 0) {
let earliest = tx.observations[0];
let best = tx.observations[0];
let bestLen = 0;
try { bestLen = JSON.parse(best.path_json || '[]').length; } catch {}
for (let i = 1; i < tx.observations.length; i++) {
if (tx.observations[i].timestamp < earliest.timestamp) earliest = tx.observations[i];
let len = 0;
try { len = JSON.parse(tx.observations[i].path_json || '[]').length; } catch {}
if (len > bestLen) { best = tx.observations[i]; bestLen = len; }
}
tx.observer_id = earliest.observer_id;
tx.observer_name = earliest.observer_name;
tx.snr = earliest.snr;
tx.rssi = earliest.rssi;
tx.path_json = earliest.path_json;
tx.direction = earliest.direction;
tx.observer_id = best.observer_id;
tx.observer_name = best.observer_name;
tx.snr = best.snr;
tx.rssi = best.rssi;
tx.path_json = best.path_json;
tx.direction = best.direction;
}
}
@@ -228,6 +233,12 @@ class PacketStore {
if (pkt.timestamp < tx.first_seen) {
tx.first_seen = pkt.timestamp;
tx.timestamp = pkt.timestamp;
}
// Update display path if new observation has longer path
let newPathLen = 0, curPathLen = 0;
try { newPathLen = JSON.parse(pkt.path_json || '[]').length; } catch {}
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
if (newPathLen > curPathLen) {
tx.observer_id = pkt.observer_id;
tx.observer_name = pkt.observer_name;
tx.path_json = pkt.path_json;
@@ -377,10 +388,16 @@ class PacketStore {
this.byTxId.set(tx.id, tx);
this._indexByNode(tx);
} else {
// Update first_seen if earlier — also update observer + path to match
// Update first_seen if earlier
if (row.timestamp < tx.first_seen) {
tx.first_seen = row.timestamp;
tx.timestamp = row.timestamp;
}
// Update display path if new observation has longer path
let newPathLen = 0, curPathLen = 0;
try { newPathLen = JSON.parse(row.path_json || '[]').length; } catch {}
try { curPathLen = JSON.parse(tx.path_json || '[]').length; } catch {}
if (newPathLen > curPathLen) {
tx.observer_id = row.observer_id;
tx.observer_name = row.observer_name;
tx.path_json = row.path_json;
@@ -584,6 +601,7 @@ class PacketStore {
// Already grouped by hash — just format for backward compat
const sorted = filtered.map(tx => ({
hash: tx.hash,
first_seen: tx.first_seen || tx.timestamp,
count: tx.observation_count,
observer_count: new Set(tx.observations.map(o => o.observer_id).filter(Boolean)).size,
latest: tx.observations.length ? tx.observations.reduce((max, o) => o.timestamp > max ? o.timestamp : max, tx.observations[0].timestamp) : tx.timestamp,
+26 -25
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774390747">
<link rel="stylesheet" href="home.css?v=1774390747">
<link rel="stylesheet" href="live.css?v=1774390747">
<link rel="stylesheet" href="style.css?v=1774418278">
<link rel="stylesheet" href="home.css?v=1774418278">
<link rel="stylesheet" href="live.css?v=1774418278">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,27 +81,28 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774390747"></script>
<script src="customize.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774390747"></script>
<script src="hop-resolver.js?v=1774390747"></script>
<script src="hop-display.js?v=1774390747"></script>
<script src="app.js?v=1774390747"></script>
<script src="home.js?v=1774390747"></script>
<script src="packet-filter.js?v=1774390747"></script>
<script src="packets.js?v=1774390747"></script>
<script src="map.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774390747" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774418278"></script>
<script src="customize.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774418278"></script>
<script src="hop-resolver.js?v=1774418278"></script>
<script src="hop-display.js?v=1774418278"></script>
<script src="app.js?v=1774418278"></script>
<script src="home.js?v=1774418278"></script>
<script src="packet-filter.js?v=1774418278"></script>
<script src="packets.js?v=1774418278"></script>
<script src="map.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+323 -211
View File
@@ -129,14 +129,11 @@
loadNodes(targetTs);
// Fetch packets from scrub point forward (ASC order, no limit clipping from the wrong end)
fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(fetchFrom)}&order=asc`)
fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(fetchFrom)}&order=asc`)
.then(r => r.json())
.then(data => {
const pkts = data.packets || []; // already ASC from server
const replayEntries = pkts.map(p => ({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
}));
const pkts = data.packets || [];
const replayEntries = expandToBufferEntries(pkts);
if (replayEntries.length === 0) {
vcrSetMode('PAUSED');
return;
@@ -188,16 +185,14 @@
// Fetch packets from DB for the time window
const now = Date.now();
const from = new Date(now - ms).toISOString();
fetch(`/api/packets?limit=2000&grouped=false&since=${encodeURIComponent(from)}`)
fetch(`/api/packets?limit=2000&grouped=false&expand=observations&since=${encodeURIComponent(from)}`)
.then(r => r.json())
.then(data => {
const pkts = (data.packets || []).reverse(); // oldest first
// Prepend to buffer (avoid duplicates by ID)
const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean));
const newEntries = pkts.filter(p => !existingIds.has(p.id)).map(p => ({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
}));
const filtered = pkts.filter(p => !existingIds.has(p.id));
const newEntries = expandToBufferEntries(filtered);
VCR.buffer = [...newEntries, ...VCR.buffer];
VCR.playhead = 0;
VCR.speed = 1;
@@ -210,30 +205,45 @@
function startReplay() {
stopReplay();
// Pre-aggregate VCR buffer by hash so each tick renders a full tree
const hashGroups = new Map();
for (const entry of VCR.buffer) {
const hash = entry.pkt.hash || ('nohash-' + hashGroups.size);
if (hashGroups.has(hash)) {
hashGroups.get(hash).packets.push(entry.pkt);
if (entry.ts > hashGroups.get(hash).ts) hashGroups.get(hash).ts = entry.ts;
} else {
hashGroups.set(hash, { packets: [entry.pkt], ts: entry.ts });
}
}
const replayGroups = [...hashGroups.values()].sort((a, b) => a.ts - b.ts);
console.log('[vcr] ' + replayGroups.length + ' groups from ' + VCR.buffer.length + ' buffer entries. Top 3:', replayGroups.slice(0,3).map(g => g.packets.length + ' obs'));
let groupIdx = 0;
function tick() {
if (VCR.mode !== 'REPLAY') return;
if (VCR.playhead >= VCR.buffer.length) {
// Try to fetch the next page before going live
if (groupIdx >= replayGroups.length) {
fetchNextReplayPage().then(hasMore => {
if (hasMore) tick();
if (hasMore) vcrResumeLive();
else vcrResumeLive();
});
return;
}
const entry = VCR.buffer[VCR.playhead];
animatePacket(entry.pkt);
updateVCRClock(entry.ts);
const group = replayGroups[groupIdx];
renderPacketTree(group.packets);
updateVCRClock(group.ts);
updateVCRLcd();
VCR.playhead++;
VCR.playhead = Math.min(VCR.buffer.length, VCR.playhead + group.packets.length);
updateVCRUI();
updateTimelinePlayhead();
groupIdx++;
// Calculate delay to next packet
let delay = 150; // default
if (VCR.playhead < VCR.buffer.length) {
const nextEntry = VCR.buffer[VCR.playhead];
const realGap = nextEntry.ts - entry.ts;
delay = Math.min(2000, Math.max(80, realGap)) / VCR.speed;
let delay = 300;
if (groupIdx < replayGroups.length) {
const nextGroup = replayGroups[groupIdx];
const realGap = nextGroup.ts - group.ts;
delay = Math.min(2000, Math.max(100, realGap)) / VCR.speed;
}
VCR.replayTimer = setTimeout(tick, delay);
}
@@ -245,16 +255,12 @@
const last = VCR.buffer[VCR.buffer.length - 1];
if (!last) return Promise.resolve(false);
const since = new Date(last.ts + 1).toISOString(); // +1ms to avoid dupe
return fetch(`/api/packets?limit=10000&grouped=false&since=${encodeURIComponent(since)}&order=asc`)
return fetch(`/api/packets?limit=10000&grouped=false&expand=observations&since=${encodeURIComponent(since)}&order=asc`)
.then(r => r.json())
.then(data => {
const pkts = data.packets || [];
if (pkts.length === 0) return false;
const newEntries = pkts.map(p => ({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
}));
// Append to buffer, playhead stays where it is (at the end, about to read new entries)
const newEntries = expandToBufferEntries(pkts);
VCR.buffer = VCR.buffer.concat(newEntries);
return true;
})
@@ -410,12 +416,34 @@
return {
id: pkt.id, hash: pkt.hash,
raw: pkt.raw_hex,
path_json: pkt.path_json,
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
};
}
// Expand a DB packet (with optional observations[]) into VCR buffer entries
function expandToBufferEntries(pkts) {
const entries = [];
for (const p of pkts) {
if (p.observations && p.observations.length > 0) {
for (const obs of p.observations) {
entries.push({
ts: new Date(obs.timestamp || p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(Object.assign({}, p, obs, { hash: p.hash, raw_hex: p.raw_hex, decoded_json: p.decoded_json }))
});
}
} else {
entries.push({
ts: new Date(p.timestamp || p.created_at).getTime(),
pkt: dbPacketToLive(p)
});
}
}
return entries;
}
// Buffer a packet from WS
let _tabHidden = false;
document.addEventListener('visibilitychange', () => {
@@ -459,12 +487,12 @@
const entry = { packets: [pkt], timer: setTimeout(() => {
const buffered = propagationBuffer.get(hash);
propagationBuffer.delete(hash);
if (buffered) animateRealisticPropagation(buffered.packets);
if (buffered) renderPacketTree(buffered.packets);
}, PROPAGATION_BUFFER_MS) };
propagationBuffer.set(hash, entry);
}
} else {
animatePacket(pkt);
renderPacketTree([pkt]);
}
updateTimeline();
} else if (VCR.mode === 'PAUSED') {
@@ -733,22 +761,10 @@
const parsed = JSON.parse(replayData);
const packets = Array.isArray(parsed) ? parsed : [parsed];
vcrPause(); // suppress live packets
if (packets.length > 1 && packets[0].hash) {
// Multiple observations — use realistic propagation (animate all paths at once)
setTimeout(() => {
if (typeof animateRealisticPropagation === 'function') {
animateRealisticPropagation(packets);
} else {
// Fallback: stagger animations
packets.forEach((p, i) => setTimeout(() => animatePacket(p), i * 400));
}
}, 1500);
} else {
setTimeout(() => animatePacket(packets[0]), 1500);
}
setTimeout(() => renderPacketTree(packets, true), 1500);
} catch {}
} else {
replayRecent();
// replayRecent(); // disabled — live page starts empty, fills from WS
}
map.on('zoomend', rescaleMarkers);
@@ -1317,24 +1333,74 @@
function rebuildFeedList() {
const feed = document.getElementById('liveFeed');
if (!feed) return;
// Remove all feed items but keep the hide button and resize handle
feed.querySelectorAll('.live-feed-item').forEach(el => el.remove());
// Re-add from VCR buffer (most recent first, up to 25)
const entries = VCR.buffer.slice(-100).reverse();
let count = 0;
for (const entry of entries) {
if (count >= 25) break;
feedDedup.clear();
// Aggregate VCR buffer by hash, then create one feed item per unique hash
const byHash = new Map();
for (const entry of VCR.buffer) {
const pkt = entry.pkt;
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) continue;
const hash = pkt.hash;
if (hash && byHash.has(hash)) {
const existing = byHash.get(hash);
existing.packets.push(pkt);
existing.count++;
if (entry.ts > existing.latestTs) { existing.latestTs = entry.ts; existing.latestPkt = pkt; }
} else {
byHash.set(hash || ('nohash-' + byHash.size), { packets: [pkt], count: 1, latestTs: entry.ts, latestPkt: pkt, hash });
}
}
// Sort by latest timestamp desc, take top 25
const sorted = [...byHash.values()].sort((a, b) => b.latestTs - a.latestTs).slice(0, 25);
for (const group of sorted) {
const pkt = Object.assign({}, group.latestPkt, { observation_count: group.count });
const decoded = pkt.decoded || {};
const header = decoded.header || {};
const payload = decoded.payload || {};
const typeName = header.payloadTypeName || 'UNKNOWN';
const icon = PAYLOAD_ICONS[typeName] || '📦';
const hops = decoded.path?.hops || [];
const color = TYPE_COLORS[typeName] || '#6b7280';
addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed);
count++;
// Find longest path across all observations for display
let longestHops = decoded.path?.hops || [];
for (const op of group.packets) {
let opHops = [];
if (op.path_json) {
try { opHops = typeof op.path_json === 'string' ? JSON.parse(op.path_json) : op.path_json; } catch {}
} else if (op.decoded?.path?.hops) {
opHops = op.decoded.path.hops;
}
if (opHops.length > longestHops.length) longestHops = opHops;
}
// Create feed item directly with correct count
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = longestHops.length ? `<span class="feed-hops">${longestHops.length}⇢</span>` : '';
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${group.count}</span>` : '';
const item = document.createElement('div');
item.className = 'live-feed-item';
item.setAttribute('tabindex', '0');
item.setAttribute('role', 'button');
if (group.hash) item.setAttribute('data-hash', group.hash);
item.style.cursor = 'pointer';
item.innerHTML = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
${hopStr}${obsBadge}
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date(group.latestTs || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.appendChild(item);
// Register in dedup map so replay and live updates work
if (group.hash) {
feedDedup.set(group.hash, { element: item, count: group.count, pkt, packets: group.packets, createdAt: Date.now() });
}
}
}
@@ -1397,17 +1463,54 @@
async function replayRecent() {
try {
const resp = await fetch('/api/packets?limit=8&grouped=false');
const resp = await fetch('/api/packets?limit=8&groupByHash=true');
const data = await resp.json();
const pkts = (data.packets || []).reverse();
pkts.forEach((pkt, i) => {
const livePkt = dbPacketToLive(pkt);
livePkt._ts = new Date(pkt.timestamp || pkt.created_at).getTime();
const ts = livePkt._ts;
VCR.buffer.push({ ts, pkt: livePkt });
setTimeout(() => animatePacket(livePkt), i * 400);
});
setTimeout(updateTimeline, pkts.length * 400 + 200);
const groups = (data.packets || []).reverse();
// Fetch all observations first, then stagger rendering
const allGroups = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
let observations = [];
try {
const detail = await fetch('/api/packets/' + encodeURIComponent(group.hash));
const detailData = await detail.json();
observations = detailData.observations || [];
} catch {}
const livePackets = observations.map(obs => {
const livePkt = dbPacketToLive(Object.assign({}, group, obs, {
hash: group.hash,
raw_hex: group.raw_hex,
decoded_json: group.decoded_json,
}));
livePkt._ts = new Date(obs.timestamp || group.first_seen || Date.now()).getTime();
return livePkt;
});
if (livePackets.length === 0) {
const livePkt = dbPacketToLive(group);
livePkt._ts = new Date(group.first_seen || group.latest || Date.now()).getTime();
livePackets.push(livePkt);
}
livePackets.forEach(lp => VCR.buffer.push({ ts: lp._ts, pkt: lp }));
allGroups.push(livePackets);
}
// Render with real timing gaps between packets
// Sort by earliest timestamp
allGroups.sort((a, b) => (a[0]?._ts || 0) - (b[0]?._ts || 0));
let lastTs = allGroups[0]?.[0]?._ts || Date.now();
for (let i = 0; i < allGroups.length; i++) {
const groupTs = allGroups[i][0]?._ts || lastTs;
// Real gap between this packet and the previous, capped at 3s for UX
const gap = i === 0 ? 0 : Math.min(3000, Math.max(200, groupTs - lastTs));
await new Promise(resolve => setTimeout(resolve, gap));
renderPacketTree(allGroups[i]);
lastTs = groupTs;
}
updateTimeline();
} catch {}
}
@@ -1424,85 +1527,40 @@
ws.onerror = () => {};
}
function animatePacket(pkt) {
packetCount++;
pktTimestamps.push(Date.now());
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
const decoded = pkt.decoded || {};
const header = decoded.header || {};
const payload = decoded.payload || {};
const typeName = header.payloadTypeName || 'UNKNOWN';
const icon = PAYLOAD_ICONS[typeName] || '📦';
const hops = decoded.path?.hops || [];
const color = TYPE_COLORS[typeName] || '#6b7280';
if (window.MeshAudio) MeshAudio.sonifyPacket(pkt);
addFeedItem(icon, typeName, payload, hops, color, pkt);
addRainDrop(pkt);
// Spawn extra rain columns for multiple observations with varied hop counts
const obsCount = pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1;
const baseHops = (pkt.decoded?.path?.hops || []).length || 1;
for (let i = 1; i < obsCount; i++) {
const variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1); // ±1 hop
setTimeout(() => addRainDrop(pkt, variedHops), i * 150);
}
// Favorites filter: skip animation if packet doesn't involve a favorited node
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
// If ADVERT, ensure node appears on map
if (typeName === 'ADVERT' && payload.pubKey) {
const key = payload.pubKey;
if (!nodeMarkers[key] && payload.lat != null && payload.lon != null && !(payload.lat === 0 && payload.lon === 0)) {
const n = { public_key: key, name: payload.name || key.slice(0,8), role: payload.role || 'unknown', lat: payload.lat, lon: payload.lon };
nodeData[key] = n;
addNodeMarker(n);
if (window.HopResolver) HopResolver.init(Object.values(nodeData));
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
}
}
const hopPositions = resolveHopPositions(hops, payload);
if (hopPositions.length === 0) return;
if (hopPositions.length === 1) { pulseNode(hopPositions[0].key, hopPositions[0].pos, typeName); return; }
animatePath(hopPositions, typeName, color, pkt.raw);
}
function animateRealisticPropagation(packets) {
if (!packets.length) return;
// === UNIFIED PACKET RENDERER ===
// ONE function for all rendering: WS arrival, DB load, replay button, VCR playback.
// Takes an array of observations (same hash) and renders the complete path tree.
function renderPacketTree(packets, isReplay) {
if (!packets || !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 || {};
const typeName = header.payloadTypeName || 'UNKNOWN';
const icon = PAYLOAD_ICONS[typeName] || '📦';
const color = TYPE_COLORS[typeName] || '#6b7280';
const obsCount = packets.length;
packetCount += packets.length;
pktTimestamps.push(Date.now());
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
// --- Counters ---
if (!isReplay) {
packetCount += obsCount;
pktTimestamps.push(Date.now());
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
}
// Favorites filter: skip if none of the packets involve a favorite
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
// --- Favorites filter ---
if (showOnlyFavorites && !packets.some(function(p) { return packetInvolvesFavorite(p); })) return;
const consolidated = Object.assign({}, first, { observation_count: packets.length });
if (window.MeshAudio) MeshAudio.sonifyPacket(consolidated);
// Add single consolidated feed item for the group
const allHops = (decoded.path?.hops) || [];
addFeedItem(icon, typeName, payload, allHops, color, consolidated);
// Rain drop per observation in the group
packets.forEach((p, i) => setTimeout(() => addRainDrop(p), i * 150));
// Ensure ADVERT nodes appear
for (const pkt of packets) {
const d = pkt.decoded || {};
const h = d.header || {};
const p = d.payload || {};
// --- Ensure ADVERT nodes appear on map ---
for (var pi = 0; pi < packets.length; pi++) {
var pkt = packets[pi];
var d = pkt.decoded || {};
var h = d.header || {};
var p = d.payload || {};
if (h.payloadTypeName === 'ADVERT' && p.pubKey) {
const key = p.pubKey;
var 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 };
var 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);
if (window.HopResolver) HopResolver.init(Object.values(nodeData));
@@ -1511,56 +1569,97 @@
}
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(',');
// --- Build consolidated packet for feed + audio ---
const consolidated = Object.assign({}, first, { observation_count: obsCount });
// --- Audio: sonify with correct observation count for multi-voice ---
if (window.MeshAudio) MeshAudio.sonifyPacket(consolidated);
// --- Feed item (one per hash group) ---
if (!isReplay) {
// Find longest path across all observations for display
let feedHops = decoded.path?.hops || [];
for (const fp of packets) {
let fpHops = [];
if (fp.path_json) {
try { fpHops = typeof fp.path_json === 'string' ? JSON.parse(fp.path_json) : fp.path_json; } catch {}
} else if (fp.decoded?.path?.hops) {
fpHops = fp.decoded.path.hops;
}
if (fpHops.length > feedHops.length) feedHops = fpHops;
}
addFeedItem(icon, typeName, payload, feedHops, color, consolidated);
// Store all observation packets in dedup entry for replay tree
if (consolidated.hash && feedDedup.has(consolidated.hash)) {
const entry = feedDedup.get(consolidated.hash);
// Append observations — don't overwrite (each renderPacketTree call may have 1 or many)
for (const p of packets) {
if (!entry.packets.some(ep => ep.path_json === p.path_json && ep.observer === p.observer)) {
entry.packets.push(p);
}
}
}
}
// --- Rain drops: one per observation ---
var baseHops = (decoded.path?.hops || []).length || 1;
packets.forEach(function(rp, i) {
if (i === 0) { addRainDrop(rp); return; }
var variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1);
setTimeout(function() { addRainDrop(rp, variedHops); }, i * 150);
});
// --- Extract all unique paths from observations ---
// Prefer path_json (per-observer unique path) over decoded.path.hops (same for all)
var allPaths = [];
var seenPathKeys = new Set();
for (var qi = 0; qi < packets.length; qi++) {
var qpkt = packets[qi];
var qd = qpkt.decoded || {};
var qp = qd.payload || {};
var hops;
if (qpkt.path_json) {
try { hops = typeof qpkt.path_json === 'string' ? JSON.parse(qpkt.path_json) : qpkt.path_json; } catch (e) { hops = qd.path?.hops || []; }
} else {
hops = qd.path?.hops || [];
}
var 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 = `
<span class="feed-icon" style="color:${color}">${icon}</span>
<span class="feed-type" style="color:${color}">${typeName}</span>
<span class="feed-hops">${allPaths.length}⇢ ${observers.size}👁</span>
<span class="feed-text">${escapeHtml(preview)}</span>
<span class="feed-time">${new Date(first._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
`;
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);
var hopPositions = resolveHopPositions(hops, qp);
if (hopPositions.length >= 2) {
allPaths.push({ hopPositions: hopPositions, raw: qpkt.raw || first.raw });
} else if (hopPositions.length === 1) {
pulseNode(hopPositions[0].key, hopPositions[0].pos, typeName);
}
}
// If no multi-hop paths found, try the decoded path as fallback
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;
var fallbackHops = decoded.path?.hops || [];
var fallbackPositions = resolveHopPositions(fallbackHops, payload);
if (fallbackPositions.length >= 2) {
allPaths.push({ hopPositions: fallbackPositions, raw: first.raw });
} else if (fallbackPositions.length === 1) {
pulseNode(fallbackPositions[0].key, fallbackPositions[0].pos, typeName);
}
}
// Animate all paths simultaneously
for (const hopPositions of allPaths) {
animatePath(hopPositions, typeName, color, first.raw);
// --- Animate all unique paths simultaneously ---
// First path gets audio sync hook, rest are visual-only
var firstPathDone = false;
for (var ai = 0; ai < allPaths.length; ai++) {
var onHop = null;
if (!firstPathDone && obsCount === 1 && window.MeshAudio) {
// For single observation, try sync voice on the first path
var voice = window._meshAudioVoices && window._meshAudioVoices[MeshAudio.getVoiceName()];
if (voice && voice.createSync && MeshAudio.isEnabled()) {
var audioSync = voice.createSync(consolidated);
if (audioSync) onHop = audioSync.playHop;
}
}
firstPathDone = true;
animatePath(allPaths[ai].hopPositions, typeName, color, allPaths[ai].raw, onHop);
}
}
@@ -1619,7 +1718,7 @@
return raw.filter(h => h.pos != null);
}
function animatePath(hopPositions, typeName, color, rawHex) {
function animatePath(hopPositions, typeName, color, rawHex, onHop) {
if (!animLayer || !pathsLayer) return;
activeAnims++;
document.getElementById('liveAnimCount').textContent = activeAnims;
@@ -1631,6 +1730,8 @@
document.getElementById('liveAnimCount').textContent = activeAnims;
return;
}
// Audio hook: notify per-hop callback
if (onHop) try { onHop(hopIndex, hopPositions.length, hopPositions[hopIndex]); } catch (e) {}
const hp = hopPositions[hopIndex];
const isGhost = hp.ghost;
@@ -2106,52 +2207,56 @@
feed.appendChild(item);
}
// Track recent feed items by hash for deduplication (hash -> {element, count, pkt})
const feedHashMap = new Map();
const FEED_DEDUP_WINDOW_MS = 30000; // merge duplicates within 30s
// Dedup: hash → {element, count, pkt, packets[], createdAt}
// First packet with hash A creates a feed item.
// Any packet with hash A arriving within 30s updates that item's count.
// packets[] stores all observations for replay.
const feedDedup = new Map();
const DEDUP_WINDOW = 30000;
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
const feed = document.getElementById('liveFeed');
if (!feed) return;
// Favorites filter: skip feed item if packet doesn't involve a favorite
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
const hash = pkt.hash;
const incomingObs = pkt.observation_count || 1;
// Hash-based deduplication: if a feed item with the same hash exists and is recent, update it
if (hash && feedHashMap.has(hash)) {
const entry = feedHashMap.get(hash);
if (entry.element.parentNode && (Date.now() - entry.addedAt) < FEED_DEDUP_WINDOW_MS) {
entry.count++;
// Update the observation badge
// Dedup: same hash within window → update existing entry
if (hash && feedDedup.has(hash)) {
const entry = feedDedup.get(hash);
if ((Date.now() - entry.createdAt) < DEDUP_WINDOW) {
entry.count += incomingObs;
entry.packets.push(pkt);
// Ensure badge exists
let badge = entry.element.querySelector('.badge-obs');
if (!badge) {
badge = document.createElement('span');
badge.className = 'badge badge-obs';
badge.style.cssText = 'font-size:10px;margin-left:4px';
// Insert after feed-hops or feed-type
const anchor = entry.element.querySelector('.feed-hops') || entry.element.querySelector('.feed-type');
if (anchor) anchor.after(badge);
else entry.element.prepend(badge);
const ref = entry.element.querySelector('.feed-hops') || entry.element.querySelector('.feed-type');
if (ref) ref.after(badge); else entry.element.appendChild(badge);
}
badge.textContent = '👁 ' + entry.count;
// Flash the item to indicate update
// Flash + move to top
entry.element.classList.remove('live-feed-enter');
void entry.element.offsetWidth; // force reflow
entry.element.classList.add('live-feed-enter');
requestAnimationFrame(() => { requestAnimationFrame(() => entry.element.classList.remove('live-feed-enter')); });
// Move to top of feed
requestAnimationFrame(() => requestAnimationFrame(() => entry.element.classList.remove('live-feed-enter')));
// Re-add to DOM top (works even if it was trimmed out)
feed.prepend(entry.element);
// Update stored pkt with merged observation count
entry.pkt.observation_count = entry.count;
return;
}
// Window expired — fall through to create new entry
feedDedup.delete(hash);
}
// Create new feed item
const text = payload.text || payload.name || '';
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
const obsCount = pkt.observation_count || 1;
const obsBadge = obsCount > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${obsCount}</span>` : '';
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${incomingObs}</span>` : '';
const item = document.createElement('div');
item.className = 'live-feed-item live-feed-enter';
@@ -2168,17 +2273,17 @@
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);
requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); });
requestAnimationFrame(() => requestAnimationFrame(() => item.classList.remove('live-feed-enter')));
while (feed.children.length > 25) feed.removeChild(feed.lastChild);
// Register in dedup map
// Register
if (hash) {
feedHashMap.set(hash, { element: item, count: obsCount, pkt, addedAt: Date.now() });
// Prune old entries periodically
if (feedHashMap.size > 50) {
const cutoff = Date.now() - FEED_DEDUP_WINDOW_MS;
for (const [k, v] of feedHashMap) {
if (v.addedAt < cutoff) feedHashMap.delete(k);
feedDedup.set(hash, { element: item, count: incomingObs, pkt, packets: [pkt], createdAt: Date.now() });
// Prune stale entries
if (feedDedup.size > 100) {
const cutoff = Date.now() - DEDUP_WINDOW;
for (const [k, v] of feedDedup) {
if (v.createdAt < cutoff) feedDedup.delete(k);
}
}
}
@@ -2219,7 +2324,14 @@
<button class="fdc-replay">↻ Replay</button>
`;
card.querySelector('.fdc-close').addEventListener('click', (e) => { e.stopPropagation(); card.remove(); });
card.querySelector('.fdc-replay').addEventListener('click', (e) => { e.stopPropagation(); animatePacket(pkt); });
card.querySelector('.fdc-replay').addEventListener('click', (e) => {
e.stopPropagation();
const dedupEntry = pkt.hash && feedDedup.get(pkt.hash);
const replayPkts = (dedupEntry && dedupEntry.packets.length > 1) ? dedupEntry.packets : [pkt];
const uniquePaths = new Set(replayPkts.map(p => p.path_json || JSON.stringify(p.decoded?.path?.hops || [])));
console.log('[replay] hash=' + pkt.hash + ' pkts=' + replayPkts.length + ' uniquePaths=' + uniquePaths.size, [...uniquePaths].slice(0, 3));
renderPacketTree(replayPkts, true);
});
document.addEventListener('click', function dismiss(e) {
if (!card.contains(e.target) && !anchor.contains(e.target)) { card.remove(); document.removeEventListener('click', dismiss); }
});
@@ -2262,7 +2374,7 @@
recentPaths = [];
packetCount = 0; activeAnims = 0;
nodeActivity = {}; pktTimestamps = [];
feedHashMap.clear();
feedDedup.clear();
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
}
+1 -7
View File
@@ -205,12 +205,6 @@ class TTLCache {
}
const cache = new TTLCache();
// Seed DB only when explicitly requested via --seed flag or SEED_DB=true env var
if (process.argv.includes('--seed') || process.env.SEED_DB === 'true') {
db.seed();
}
const app = express();
function createServer(app, cfg) {
@@ -650,7 +644,7 @@ for (const source of mqttSources) {
const fullPacket = pktStore.getById(packetId) || pktStore.byHash.get(pktData.hash) || pktData;
const tx = pktStore.byHash.get(pktData.hash);
const observation_count = tx ? tx.observation_count : 1;
const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: pktData.hash, observer: observerId, packet: fullPacket, observation_count };
const broadcastData = { id: packetId, raw: msg.raw, decoded, snr: msg.SNR, rssi: msg.RSSI, hash: pktData.hash, observer: observerId, observer_name: msg.origin || null, path_json: pktData.path_json, packet: fullPacket, observation_count };
broadcast({ type: 'packet', data: broadcastData });
if (decoded.header.payloadTypeName === 'GRP_TXT') {