Add Distance/Range analytics tab

New /api/analytics/distance endpoint that:
- Resolves path hops to nodes with valid GPS coordinates
- Calculates haversine distances between consecutive hops
- Separates stats by link type: R↔R, C↔R, C↔C
- Returns top longest hops, longest paths, category stats, histogram, time series
- Filters out invalid GPS (null, 0/0) and sanity-checks >1000km
- Supports region filtering and caching

New Distance tab in analytics UI with:
- Summary cards (total hops, paths, avg/max distance)
- Link type breakdown table
- Distance histogram
- Average distance over time sparkline
- Top 20 longest hops leaderboard
- Top 10 longest multi-hop paths table
This commit is contained in:
you committed 2026-03-21 17:33:33 +00:00
1 parent 47fa32f982
commit 7d46d96563
3 files changed
+225 -1

No files matched your search

+67
View File
@@ -76,6 +76,7 @@
<button class="tab-btn" data-tab="collisions">Hash Collisions</button>
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
<button class="tab-btn" data-tab="nodes">Nodes</button>
<button class="tab-btn" data-tab="distance">Distance</button>
</div>
</div>
<div id="analyticsContent" class="analytics-content">
@@ -148,6 +149,7 @@
case 'collisions': await renderCollisionTab(el, d.hashData); break;
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
case 'distance': await renderDistanceTab(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
@@ -1312,6 +1314,71 @@
}
}
async function renderDistanceTab(el) {
try {
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
const s = data.summary;
let html = `<div class="analytics-grid">
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
</div>`;
// Category stats
const cats = data.catStats;
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
}
html += `</tbody></table></div>`;
// Histogram
if (data.distHistogram && data.distHistogram.bins) {
const h = histogram(null, 0, '#22c55e');
// Use pre-computed histogram
const buckets = data.distHistogram.bins.map(b => b.count);
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, '#22c55e')}</div>`;
}
// Distance over time
if (data.distOverTime && data.distOverTime.length > 1) {
html += `<div class="analytics-section"><h3>Average Distance Over Time</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`;
}
// Top hops leaderboard
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th></tr></thead><tbody>`;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}/analytics" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}/analytics" class="analytics-link">${esc(h.toName)}</a>` : esc(h.toName || '?');
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td></tr>`;
});
html += `</tbody></table></div>`;
// Top paths
if (data.topPaths.length) {
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th></tr></thead><tbody>`;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td></tr>`;
});
html += `</tbody></table></div>`;
}
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load distance analytics: ${esc(e.message)}</div>`;
}
}
function destroy() { _analyticsData = {}; }
registerPage('analytics', { init, destroy });
+1 -1
View File
@@ -88,7 +88,7 @@
<script src="channels.js?v=1774075538" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774354200" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774350000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774354100" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774400000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774072222" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
+157
View File
@@ -1643,6 +1643,163 @@ app.get('/api/analytics/channels', (req, res) => {
res.json(_chanResult);
});
app.get('/api/analytics/distance', (req, res) => {
const { region } = req.query;
const regionObsIds = getObserverIdsForRegions(region);
const _ck = 'analytics:distance' + (region ? ':' + region : '');
const _c = cache.get(_ck); if (_c) return res.json(_c);
const arrMin = arr => { let m = Infinity; for (const v of arr) if (v < m) m = v; return m === Infinity ? 0 : m; };
const arrMax = arr => { let m = -Infinity; for (const v of arr) if (v > m) m = v; return m === -Infinity ? 0 : m; };
const median = arr => { if (!arr.length) return 0; const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)]; };
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
const allNodes = db.db.prepare('SELECT public_key, name, lat, lon, role FROM nodes WHERE name IS NOT NULL').all();
const nodeByPk = new Map(allNodes.map(n => [n.public_key, n]));
// Build prefix lookup for hop resolution
const resolveHop = (hop) => {
const h = hop.toLowerCase();
const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(h));
if (candidates.length === 1) return candidates[0];
if (candidates.length > 1) {
// prefer ones with valid GPS
const withLoc = candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
return withLoc.length ? withLoc[0] : candidates[0];
}
return null;
};
const validGps = n => n && n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
const isRepeater = n => n && n.role && n.role.toLowerCase().includes('repeater');
const packets = pktStore.filter(p => p.path_json && p.path_json !== '[]' && (!regionObsIds || regionObsIds.has(p.observer_id)));
// Collect hops with distances
const allHops = []; // { from, to, dist, type, snr, hash, timestamp }
const pathTotals = []; // { hash, totalDist, hopCount, timestamp, hops: [{from,to,dist}] }
const catDists = { 'R↔R': [], 'C↔R': [], 'C↔C': [] };
const distByHour = {}; // hourBucket → [distances]
for (const p of packets) {
let hops;
try { hops = JSON.parse(p.path_json); } catch { continue; }
if (!hops.length) continue;
// Resolve all hops to nodes
const resolved = hops.map(h => resolveHop(h));
// Also try to resolve sender from decoded_json
let senderNode = null;
if (p.decoded_json) {
try {
const dec = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : p.decoded_json;
if (dec.pubKey) senderNode = nodeByPk.get(dec.pubKey) || null;
} catch {}
}
// Build chain: sender → hop0 → hop1 → ... → observer
// For distance we only measure consecutive hops where both have valid GPS
const chain = [];
if (senderNode && validGps(senderNode)) chain.push(senderNode);
for (const r of resolved) {
if (r && validGps(r)) chain.push(r);
}
if (chain.length < 2) continue;
const hourBucket = p.timestamp ? new Date(p.timestamp).toISOString().slice(0, 13) : null;
let pathDist = 0;
const pathHops = [];
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i], b = chain[i + 1];
const dist = haversine(a.lat, a.lon, b.lat, b.lon);
if (dist > 1000) continue; // sanity: skip > 1000km (likely bad GPS)
const aRep = isRepeater(a), bRep = isRepeater(b);
let type;
if (aRep && bRep) type = 'R↔R';
else if (!aRep && !bRep) type = 'C↔C';
else type = 'C↔R';
const hop = { fromName: a.name, fromPk: a.public_key, toName: b.name, toPk: b.public_key, dist: Math.round(dist * 100) / 100, type, snr: p.snr || null, hash: p.hash, timestamp: p.timestamp };
allHops.push(hop);
catDists[type].push(dist);
pathDist += dist;
pathHops.push({ fromName: a.name, toName: b.name, dist: hop.dist });
if (hourBucket) {
if (!distByHour[hourBucket]) distByHour[hourBucket] = [];
distByHour[hourBucket].push(dist);
}
}
if (pathHops.length > 0) {
pathTotals.push({ hash: p.hash, totalDist: Math.round(pathDist * 100) / 100, hopCount: pathHops.length, timestamp: p.timestamp, hops: pathHops });
}
}
// Top longest hops
allHops.sort((a, b) => b.dist - a.dist);
const topHops = allHops.slice(0, 50);
// Top longest paths
pathTotals.sort((a, b) => b.totalDist - a.totalDist);
const topPaths = pathTotals.slice(0, 20);
// Category stats
const catStats = {};
for (const [cat, dists] of Object.entries(catDists)) {
if (!dists.length) { catStats[cat] = { count: 0, avg: 0, median: 0, min: 0, max: 0 }; continue; }
const avg = dists.reduce((s, v) => s + v, 0) / dists.length;
catStats[cat] = { count: dists.length, avg: Math.round(avg * 100) / 100, median: Math.round(median(dists) * 100) / 100, min: Math.round(arrMin(dists) * 100) / 100, max: Math.round(arrMax(dists) * 100) / 100 };
}
// Histogram of all hop distances
const allDists = allHops.map(h => h.dist);
let distHistogram = [];
if (allDists.length) {
const hMin = arrMin(allDists), hMax = arrMax(allDists);
const binCount = 25;
const binW = (hMax - hMin) / binCount || 1;
const bins = new Array(binCount).fill(0);
for (const d of allDists) {
const idx = Math.min(Math.floor((d - hMin) / binW), binCount - 1);
bins[idx]++;
}
distHistogram = { bins: bins.map((count, i) => ({ x: Math.round((hMin + i * binW) * 10) / 10, w: Math.round(binW * 10) / 10, count })), min: hMin, max: hMax };
}
// Distance over time
const timeEntries = Object.entries(distByHour).sort((a, b) => a[0].localeCompare(b[0]));
const distOverTime = timeEntries.map(([hour, dists]) => ({
hour,
avg: Math.round((dists.reduce((s, v) => s + v, 0) / dists.length) * 100) / 100,
count: dists.length
}));
// Summary
const totalDists = allHops.map(h => h.dist);
const summary = {
totalHops: allHops.length,
totalPaths: pathTotals.length,
avgDist: totalDists.length ? Math.round((totalDists.reduce((s, v) => s + v, 0) / totalDists.length) * 100) / 100 : 0,
maxDist: totalDists.length ? Math.round(arrMax(totalDists) * 100) / 100 : 0,
};
const _distResult = { summary, topHops, topPaths, catStats, distHistogram, distOverTime };
cache.set(_ck, _distResult, TTL.analyticsTopology);
res.json(_distResult);
});
app.get('/api/analytics/hash-sizes', (req, res) => {
const { region } = req.query;
const regionObsIds = getObserverIdsForRegions(region);