mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 12:32:34 +00:00
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:
1 parent
47fa32f982
commit
7d46d96563
3 files changed
+225
-1
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user