diff --git a/public/analytics.js b/public/analytics.js
index 52b1f400..986aef57 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -70,6 +70,7 @@
+
@@ -111,6 +112,7 @@
case 'topology': renderTopology(el, d.topoData); break;
case 'channels': renderChannels(el, d.chanData); break;
case 'hashsizes': renderHashSizes(el, d.hashData); break;
+ case 'subpaths': renderSubpaths(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
@@ -761,7 +763,52 @@
} catch { el.innerHTML = '
Failed to load
'; }
}
- function destroy() { delete window._analyticsData; }
+ async function renderSubpaths(el) {
+ el.innerHTML = '
Analyzing route patterns…
';
+ try {
+ const [d2, d3, d4, d5] = await Promise.all([
+ api('/analytics/subpaths?minLen=2&maxLen=2&limit=50'),
+ api('/analytics/subpaths?minLen=3&maxLen=3&limit=30'),
+ api('/analytics/subpaths?minLen=4&maxLen=4&limit=20'),
+ api('/analytics/subpaths?minLen=5&maxLen=8&limit=15')
+ ]);
+
+ function renderTable(data, title) {
+ if (!data.subpaths.length) return `
${title}
No data
`;
+ const maxCount = data.subpaths[0]?.count || 1;
+ return `
${title}
+
From ${data.totalPaths.toLocaleString()} paths with 2+ hops
+
+ | # | Route | Occurrences | % of paths | Frequency |
+
+ ${data.subpaths.map((s, i) => {
+ const barW = Math.max(2, Math.round(s.count / maxCount * 100));
+ return `
+ | ${i + 1} |
+ ${escapeHtml(s.path)} |
+ ${s.count.toLocaleString()} |
+ ${s.pct}% |
+ |
+
`;
+ }).join('')}
+
`;
+ }
+
+ 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)')}
+
`;
+ } catch (e) {
+ el.innerHTML = `
Error loading subpath data: ${e.message}
`;
+ }
+ }
+
+function destroy() { delete window._analyticsData; }
registerPage('analytics', { init, destroy });
})();
diff --git a/server.js b/server.js
index b6721229..d49c75c9 100644
--- a/server.js
+++ b/server.js
@@ -1104,6 +1104,59 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
res.json(health);
});
+// Subpath frequency analysis
+app.get('/api/analytics/subpaths', (req, res) => {
+ const minLen = Math.max(2, Number(req.query.minLen) || 2);
+ const maxLen = Number(req.query.maxLen) || 8;
+ const packets = db.db.prepare(`SELECT path_json 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();
+
+ // Simple resolver (no geographic context needed here — just first match)
+ 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 ? m.name : hop;
+ return nameCache[hop];
+ }
+
+ const subpathCounts = {};
+ let totalPaths = 0;
+
+ for (const pkt of packets) {
+ let hops;
+ try { hops = JSON.parse(pkt.path_json); } catch { continue; }
+ if (!Array.isArray(hops) || hops.length < 2) continue;
+ totalPaths++;
+
+ // Resolve all hops to names
+ const named = hops.map(h => resolveName(h));
+
+ // Extract all subpaths of length minLen..maxLen
+ for (let len = minLen; len <= Math.min(maxLen, named.length); len++) {
+ for (let start = 0; start <= named.length - len; start++) {
+ const sub = named.slice(start, start + len).join(' → ');
+ subpathCounts[sub] = (subpathCounts[sub] || 0) + 1;
+ }
+ }
+ }
+
+ // Sort by frequency, return top results
+ const limit = Number(req.query.limit) || 100;
+ const ranked = Object.entries(subpathCounts)
+ .map(([path, count]) => ({
+ path,
+ count,
+ hops: path.split(' → ').length,
+ pct: totalPaths > 0 ? Math.round(count / totalPaths * 1000) / 10 : 0
+ }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, limit);
+
+ res.json({ subpaths: ranked, totalPaths });
+});
+
// Static files + SPA fallback
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,