From a0c4290535ce2a45c35896d0fd5c636eb401e55d Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 01:27:03 +0000 Subject: [PATCH] Add subpath detail sidebar with minimap and analytics Click any route in Route Patterns to open detail sidebar showing: - Minimap with nodes and route line (green=origin, red=dest, amber=hops) - Signal quality (avg SNR/RSSI) - Activity by hour (UTC) bar chart - First/last seen timestamps - Observers that captured this route - Full parent paths containing this subpath Split layout with scrollable list + fixed sidebar, responsive on mobile. --- public/analytics.js | 115 +++++++++++++++++++++++++++++++++++++++++--- public/style.css | 25 ++++++++++ server.js | 87 +++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 8 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 35443d53..b921295a 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -788,7 +788,7 @@ const hasSelfLoop = hops.some((h, j) => j > 0 && h === hops[j - 1]); const routeDisplay = hops.map(h => esc(h)).join(' β†’ '); const prefixDisplay = rawHops.join(' β†’ '); - return ` + return ` ${i + 1} ${routeDisplay}${hasSelfLoop ? ' πŸ”„' : ''}
${esc(prefixDisplay)} ${s.count.toLocaleString()} @@ -800,19 +800,118 @@ } el.innerHTML = ` -
-

πŸ›€οΈ Route Pattern Analysis

-

Most common subpaths in the mesh β€” reveals backbone routes, bottlenecks, and preferred relay chains regardless of where they appear in the full path.

- ${renderTable(d2, 'Pairs (2-hop links)')} - ${renderTable(d3, 'Triples (3-hop chains)')} - ${renderTable(d4, 'Quads (4-hop chains)')} - ${renderTable(d5, 'Long chains (5+ hops)')} +
+
+

πŸ›€οΈ Route Pattern Analysis

+

Click a route to see details. Most common subpaths β€” reveals backbone routes, bottlenecks, and preferred relay chains.

+ ${renderTable(d2, 'Pairs (2-hop links)')} + ${renderTable(d3, 'Triples (3-hop chains)')} + ${renderTable(d4, 'Quads (4-hop chains)')} + ${renderTable(d5, 'Long chains (5+ hops)')} +
+
+
Select a route to view details
+
`; + + // Click handler for rows + el.addEventListener('click', e => { + const tr = e.target.closest('tr[data-hops]'); + if (!tr) return; + el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected')); + tr.classList.add('subpath-selected'); + loadSubpathDetail(tr.dataset.hops); + }); } catch (e) { el.innerHTML = `
Error loading subpath data: ${e.message}
`; } } + async function loadSubpathDetail(hopsStr) { + const panel = document.getElementById('subpathDetail'); + panel.innerHTML = '
Loading…
'; + try { + const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr)); + renderSubpathDetail(panel, data); + } catch (e) { + panel.innerHTML = `
Error: ${e.message}
`; + } + } + + function renderSubpathDetail(panel, data) { + const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0)); + const hasMap = nodesWithLoc.length >= 2; + const maxHour = Math.max(...data.hourDistribution, 1); + + panel.innerHTML = ` +
+

${data.nodes.map(n => esc(n.name)).join(' β†’ ')}

+
+ ${data.hops.join(' β†’ ')} + ${data.totalMatches.toLocaleString()} occurrences +
+ + ${hasMap ? '
' : ''} + +
+
πŸ“‘ Signal Quality
+ ${data.signal.avgSnr != null + ? `
Avg SNR: ${data.signal.avgSnr} dB Β· Avg RSSI: ${data.signal.avgRssi} dBm Β· ${data.signal.samples} samples
` + : '
No signal data
'} +
+ +
+
πŸ• Activity by Hour (UTC)
+
+ ${data.hourDistribution.map((c, h) => `
`).join('')} +
+
06121823
+
+ +
+
⏱️ Timeline
+
First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : 'β€”'}
+
Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : 'β€”'}
+
+ + ${data.observers.length ? ` +
+
πŸ‘οΈ Observers
+ ${data.observers.map(o => `
${esc(o.name)}: ${o.count}
`).join('')} +
` : ''} + + ${data.parentPaths.length ? ` +
+
πŸ”— Full Paths Containing This Route
+
+ ${data.parentPaths.map(p => `
${esc(p.path)} Γ—${p.count}
`).join('')} +
+
` : ''} +
`; + + // Render minimap + if (hasMap && typeof L !== 'undefined') { + const map = L.map('subpathMap', { zoomControl: false, attributionControl: false }); + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 18 }).addTo(map); + + const latlngs = []; + nodesWithLoc.forEach((n, i) => { + const ll = [n.lat, n.lon]; + latlngs.push(ll); + const isEnd = i === 0 || i === nodesWithLoc.length - 1; + L.circleMarker(ll, { + radius: isEnd ? 8 : 5, + color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b', + fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b', + fillOpacity: 0.9, weight: 2 + }).bindTooltip(n.name, { permanent: false }).addTo(map); + }); + + L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map); + map.fitBounds(L.latLngBounds(latlngs).pad(0.3)); + } + } + function destroy() { delete window._analyticsData; } registerPage('analytics', { init, destroy }); diff --git a/public/style.css b/public/style.css index 79dcd233..a400cf4a 100644 --- a/public/style.css +++ b/public/style.css @@ -1153,3 +1153,28 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } /* Hop prefix in subpath routes */ .hop-prefix { color: #9ca3af; font-size: 0.8em; } + +/* Subpath split layout */ +.subpath-layout { display: flex; gap: 0; height: calc(100vh - 160px); } +.subpath-list { flex: 1; overflow-y: auto; padding: 16px; } +.subpath-detail { width: 380px; min-width: 320px; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; } +.subpath-detail-inner h4 { margin: 0 0 4px; } +.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: #9ca3af; font-size: 0.9em; } +.subpath-section { margin: 16px 0; } +.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; } +.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; } +.subpath-selected .hop-prefix { color: rgba(255,255,255,0.6); } +tr[data-hops]:hover { background: rgba(59,130,246,0.1); } + +/* Hour distribution chart */ +.hour-chart { display: flex; align-items: flex-end; gap: 2px; height: 60px; } +.hour-bar { flex: 1; background: var(--accent, #3b82f6); border-radius: 2px 2px 0 0; min-width: 4px; } +.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: #9ca3af; } + +/* Parent paths */ +.parent-path { padding: 3px 0; border-bottom: 1px solid var(--border, #e5e7eb); } + +@media (max-width: 768px) { + .subpath-layout { flex-direction: column; height: auto; } + .subpath-detail { width: 100%; border-left: none; border-top: 1px solid var(--border, #e5e7eb); } +} diff --git a/server.js b/server.js index 278bc485..240b5880 100644 --- a/server.js +++ b/server.js @@ -1160,6 +1160,93 @@ app.get('/api/analytics/subpaths', (req, res) => { res.json({ subpaths: ranked, totalPaths }); }); +// Subpath detail β€” stats for a specific subpath (by raw hop prefixes) +app.get('/api/analytics/subpath-detail', (req, res) => { + const rawHops = (req.query.hops || '').split(',').filter(Boolean); + if (rawHops.length < 2) return res.json({ error: 'Need at least 2 hops' }); + + const packets = db.db.prepare(`SELECT path_json, snr, rssi, timestamp, decoded_json, observer_name FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all(); + const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all(); + + const nameCache = {}; + function resolveName(hop) { + if (nameCache[hop] !== undefined) return nameCache[hop]; + const h = hop.toLowerCase(); + const m = allNodes.find(n => n.public_key.toLowerCase().startsWith(h)); + nameCache[hop] = m ? { name: m.name, lat: m.lat, lon: m.lon, pubkey: m.public_key } : null; + return nameCache[hop]; + } + + const matching = []; + const parentPaths = {}; + const hourBuckets = new Array(24).fill(0); + let snrSum = 0, snrCount = 0, rssiSum = 0, rssiCount = 0; + const observers = {}; + + for (const pkt of packets) { + let hops; + try { hops = JSON.parse(pkt.path_json); } catch { continue; } + if (!Array.isArray(hops) || hops.length < rawHops.length) continue; + + // Check if rawHops appears as a contiguous subsequence + let found = false; + for (let i = 0; i <= hops.length - rawHops.length; i++) { + let match = true; + for (let j = 0; j < rawHops.length; j++) { + if (hops[i + j].toLowerCase() !== rawHops[j].toLowerCase()) { match = false; break; } + } + if (match) { found = true; break; } + } + if (!found) continue; + + matching.push(pkt); + const hr = new Date(pkt.timestamp).getUTCHours(); + hourBuckets[hr]++; + if (pkt.snr != null) { snrSum += pkt.snr; snrCount++; } + if (pkt.rssi != null) { rssiSum += pkt.rssi; rssiCount++; } + if (pkt.observer_name) observers[pkt.observer_name] = (observers[pkt.observer_name] || 0) + 1; + + // Track full parent paths (resolved names) + const fullPath = hops.map(h => { + const r = resolveName(h); + return r ? r.name : h; + }).join(' β†’ '); + parentPaths[fullPath] = (parentPaths[fullPath] || 0) + 1; + } + + // Resolve hop nodes for map + const nodes = rawHops.map(h => { + const r = resolveName(h); + return { hop: h, name: r?.name || h, lat: r?.lat || null, lon: r?.lon || null, pubkey: r?.pubkey || null }; + }); + + const topParents = Object.entries(parentPaths) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([path, count]) => ({ path, count })); + + const topObservers = Object.entries(observers) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, count]) => ({ name, count })); + + res.json({ + hops: rawHops, + nodes, + totalMatches: matching.length, + firstSeen: matching.length ? matching[0].timestamp : null, + lastSeen: matching.length ? matching[matching.length - 1].timestamp : null, + signal: { + avgSnr: snrCount ? Math.round(snrSum / snrCount * 10) / 10 : null, + avgRssi: rssiCount ? Math.round(rssiSum / rssiCount) : null, + samples: snrCount + }, + hourDistribution: hourBuckets, + parentPaths: topParents, + observers: topObservers + }); +}); + // Static files + SPA fallback app.use(express.static(path.join(__dirname, 'public'), { etag: false,