-
π€οΈ 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,