mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-25 15:22:25 +00:00
- Add 1/2/3-byte selector to Hash Issues analytics page - 1-byte and 2-byte modes show 16×16 matrix with stat cards (nodes tracked, using N-byte ID, prefix space used, prefix collisions) - 3-byte mode shows summary stat cards instead of unrenderable grid - Fix "Nodes tracked" to always show total node count across all modes - Use CSS variable colours for matrix cells (light/dark mode compatible) - Replace native title tooltips with custom styled popovers - Hide collision risk card when 3-byte mode is selected - Fix double-tooltip bug on mode switch via _matrixTipInit guard - Fix tooltip persisting outside matrix grid on mouseleave https://dev.ve7kod.ca/#/analytics Hash Issues --------- Co-authored-by: Jesse <your@email.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1952 lines
107 KiB
JavaScript
1952 lines
107 KiB
JavaScript
/* === CoreScope — analytics.js (v2 — full nerd mode) === */
|
||
'use strict';
|
||
|
||
(function () {
|
||
let _analyticsData = {};
|
||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||
|
||
// --- Status color helpers (read from CSS variables for theme support) ---
|
||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
|
||
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
|
||
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
|
||
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }
|
||
|
||
// --- SVG helpers ---
|
||
function sparkSvg(data, color, w = 120, h = 32) {
|
||
if (!data.length) return '';
|
||
const max = Math.max(...data, 1);
|
||
const pts = data.map((v, i) => {
|
||
const x = i * (w / Math.max(data.length - 1, 1));
|
||
const y = h - 2 - (v / max) * (h - 4);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Sparkline showing trend of ${data.length} data points"><title>Sparkline showing trend of ${data.length} data points</title><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||
}
|
||
|
||
function barChart(data, labels, colors, w = 800, h = 220, pad = 40) {
|
||
const max = Math.max(...data, 1);
|
||
const barW = Math.min((w - pad * 2) / data.length - 2, 30);
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Bar chart showing data distribution"><title>Bar chart showing data distribution</title>`;
|
||
// Grid
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = pad + (h - pad * 2) * i / 4;
|
||
const val = Math.round(max * (4 - i) / 4);
|
||
svg += `<line x1="${pad}" y1="${y}" x2="${w-pad}" y2="${y}" stroke="var(--border)" stroke-dasharray="2"/>`;
|
||
svg += `<text x="${pad-4}" y="${y+4}" text-anchor="end" font-size="10" fill="var(--text-muted)">${val}</text>`;
|
||
}
|
||
data.forEach((v, i) => {
|
||
const x = pad + i * ((w - pad * 2) / data.length) + barW / 2;
|
||
const bh = (v / max) * (h - pad * 2);
|
||
const y = h - pad - bh;
|
||
const c = typeof colors === 'string' ? colors : colors[i % colors.length];
|
||
svg += `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" fill="${c}" rx="2"/>`;
|
||
if (labels[i]) svg += `<text x="${x + barW/2}" y="${h - pad + 14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${labels[i]}</text>`;
|
||
});
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function histogram(data, bins, color, w = 800, h = 180) {
|
||
// Support pre-computed histogram from server { bins: [{x, w, count}], min, max }
|
||
if (data && data.bins && Array.isArray(data.bins)) {
|
||
const buckets = data.bins.map(b => b.count);
|
||
const labels = data.bins.map(b => b.x.toFixed(1));
|
||
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
|
||
}
|
||
// Legacy: raw values array
|
||
const values = data;
|
||
const min = Math.min(...values), max = Math.max(...values);
|
||
const step = (max - min) / bins;
|
||
const buckets = Array(bins).fill(0);
|
||
const labels = [];
|
||
for (let i = 0; i < bins; i++) labels.push((min + step * i).toFixed(1));
|
||
values.forEach(v => { const b = Math.min(Math.floor((v - min) / step), bins - 1); buckets[b]++; });
|
||
return { svg: barChart(buckets, labels, color, w, h), buckets, labels };
|
||
}
|
||
|
||
// --- Main ---
|
||
async function init(app) {
|
||
app.innerHTML = `
|
||
<div class="analytics-page">
|
||
<div class="analytics-header">
|
||
<h2>📊 Mesh Analytics</h2>
|
||
<p class="text-muted">Deep dive into your mesh network data</p>
|
||
<div id="analyticsRegionFilter" class="region-filter-container"></div>
|
||
<div class="analytics-tabs" id="analyticsTabs">
|
||
<button class="tab-btn active" data-tab="overview">Overview</button>
|
||
<button class="tab-btn" data-tab="rf">RF / Signal</button>
|
||
<button class="tab-btn" data-tab="topology">Topology</button>
|
||
<button class="tab-btn" data-tab="channels">Channels</button>
|
||
<button class="tab-btn" data-tab="hashsizes">Hash Stats</button>
|
||
<button class="tab-btn" data-tab="collisions">Hash Issues</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">
|
||
<div class="text-center text-muted" style="padding:40px">Loading analytics…</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Tab handling
|
||
const analyticsTabs = document.getElementById('analyticsTabs');
|
||
initTabBar(analyticsTabs);
|
||
analyticsTabs.addEventListener('click', e => {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_currentTab = btn.dataset.tab;
|
||
renderTab(_currentTab);
|
||
});
|
||
|
||
// Deep-link: #/analytics?tab=collisions
|
||
const hashParams = location.hash.split('?')[1] || '';
|
||
const urlTab = new URLSearchParams(hashParams).get('tab');
|
||
if (urlTab) {
|
||
const tabBtn = analyticsTabs.querySelector(`[data-tab="${urlTab}"]`);
|
||
if (tabBtn) {
|
||
analyticsTabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
tabBtn.classList.add('active');
|
||
_currentTab = urlTab;
|
||
}
|
||
}
|
||
|
||
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
|
||
RegionFilter.onChange(function () { loadAnalytics(); });
|
||
|
||
// Delegated click/keyboard handler for clickable table rows
|
||
const analyticsContent = document.getElementById('analyticsContent');
|
||
if (analyticsContent) {
|
||
const handler = (e) => {
|
||
const row = e.target.closest('tr[data-action="navigate"]');
|
||
if (!row) return;
|
||
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
|
||
if (e.type === 'keydown') e.preventDefault();
|
||
location.hash = row.dataset.value;
|
||
};
|
||
analyticsContent.addEventListener('click', handler);
|
||
analyticsContent.addEventListener('keydown', handler);
|
||
}
|
||
|
||
loadAnalytics();
|
||
}
|
||
|
||
let _currentTab = 'overview';
|
||
|
||
async function loadAnalytics() {
|
||
try {
|
||
_analyticsData = {};
|
||
const rqs = RegionFilter.regionQueryString();
|
||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||
]);
|
||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||
renderTab(_currentTab);
|
||
} catch (e) {
|
||
document.getElementById('analyticsContent').innerHTML =
|
||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function renderTab(tab) {
|
||
const el = document.getElementById('analyticsContent');
|
||
const d = _analyticsData;
|
||
switch (tab) {
|
||
case 'overview': renderOverview(el, d); break;
|
||
case 'rf': renderRF(el, d.rfData); break;
|
||
case 'topology': renderTopology(el, d.topoData); break;
|
||
case 'channels': renderChannels(el, d.chanData); break;
|
||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||
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(() => {
|
||
el.querySelectorAll('.analytics-table').forEach((tbl, i) => {
|
||
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
|
||
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||
});
|
||
// #206 — Wrap analytics tables in scroll containers on mobile
|
||
el.querySelectorAll('.analytics-table').forEach(tbl => {
|
||
if (!tbl.parentElement.classList.contains('analytics-table-scroll')) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'analytics-table-scroll';
|
||
tbl.parentElement.insertBefore(wrapper, tbl);
|
||
wrapper.appendChild(tbl);
|
||
}
|
||
});
|
||
});
|
||
// Deep-link scroll to section within tab
|
||
const sectionId = new URLSearchParams((location.hash.split('?')[1] || '')).get('section');
|
||
if (sectionId) {
|
||
setTimeout(() => {
|
||
const target = document.getElementById(sectionId);
|
||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}, 400);
|
||
}
|
||
}
|
||
|
||
// ===================== OVERVIEW =====================
|
||
function renderOverview(el, d) {
|
||
const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData;
|
||
el.innerHTML = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}</div>
|
||
<div class="stat-label">Total Transmissions</div>
|
||
<div class="stat-spark">${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.totalPackets.toLocaleString()}</div>
|
||
<div class="stat-label">Observations with Signal</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${topo.uniqueNodes}</div>
|
||
<div class="stat-label">Unique Nodes</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${sf(rf.snr.avg, 1)} dB</div>
|
||
<div class="stat-label">Avg SNR</div>
|
||
<div class="stat-detail">${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${sf(rf.rssi.avg, 0)} dBm</div>
|
||
<div class="stat-label">Avg RSSI</div>
|
||
<div class="stat-detail">${rf.rssi.min} to ${rf.rssi.max}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${sf(topo.avgHops, 1)}</div>
|
||
<div class="stat-label">Avg Hops</div>
|
||
<div class="stat-detail">max ${topo.maxHops}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${ch.activeChannels}</div>
|
||
<div class="stat-label">Active Channels</div>
|
||
<div class="stat-detail">${ch.decryptable} decryptable</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${rf.avgPacketSize} B</div>
|
||
<div class="stat-label">Avg Packet Size</div>
|
||
<div class="stat-detail">${rf.minPacketSize}–${rf.maxPacketSize} B</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${((rf.timeSpanHours || 1)).toFixed(1)}h</div>
|
||
<div class="stat-label">Data Span</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Packets / Hour</h3>
|
||
${barChart(rf.packetsPerHour.map(h=>h.count), rf.packetsPerHour.map(h=>h.hour.slice(11)+'h'), 'var(--accent)')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📦 Payload Type Mix</h3>
|
||
${renderPayloadPie(rf.payloadTypes)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>🔗 Hop Count Distribution</h3>
|
||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPayloadPie(types) {
|
||
const total = types.reduce((s, t) => s + t.count, 0);
|
||
const colors = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16'];
|
||
let html = '<div class="payload-bars">';
|
||
types.forEach((t, i) => {
|
||
const pct = (t.count / total * 100).toFixed(1);
|
||
const w = Math.max(t.count / total * 100, 1);
|
||
html += `<div class="payload-bar-row">
|
||
<div class="payload-bar-label"><span class="legend-dot" style="background:${colors[i]}"></span>${t.name}</div>
|
||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${w}%;background:${colors[i]}"></div></div>
|
||
<div class="payload-bar-value">${t.count} <span class="text-muted">(${pct}%)</span></div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== RF / SIGNAL =====================
|
||
function renderRF(el, rf) {
|
||
const snrHist = histogram(rf.snrValues, 20, statusGreen());
|
||
const rssiHist = histogram(rf.rssiValues, 20, accentColor());
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📶 SNR Distribution</h3>
|
||
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
|
||
${snrHist.svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${sf(rf.snr.min, 1)} dB</strong></span>
|
||
<span>Mean: <strong>${sf(rf.snr.avg, 1)} dB</strong></span>
|
||
<span>Median: <strong>${sf(rf.snr.median, 1)} dB</strong></span>
|
||
<span>Max: <strong>${sf(rf.snr.max, 1)} dB</strong></span>
|
||
<span>σ: <strong>${sf(rf.snr.stddev, 1)} dB</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📡 RSSI Distribution</h3>
|
||
<p class="text-muted">Received Signal Strength (closer to 0 = stronger)</p>
|
||
${rssiHist.svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${rf.rssi.min} dBm</strong></span>
|
||
<span>Mean: <strong>${sf(rf.rssi.avg, 0)} dBm</strong></span>
|
||
<span>Median: <strong>${rf.rssi.median} dBm</strong></span>
|
||
<span>Max: <strong>${rf.rssi.max} dBm</strong></span>
|
||
<span>σ: <strong>${sf(rf.rssi.stddev, 1)} dBm</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🎯 SNR vs RSSI Scatter</h3>
|
||
<p class="text-muted">Each dot = one packet. Cluster position reveals link quality.</p>
|
||
${renderScatter(rf.scatterData)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>📊 SNR by Payload Type</h3>
|
||
${renderSNRByType(rf.snrByType)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Signal Quality Over Time</h3>
|
||
${renderSignalTimeline(rf.signalOverTime)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>📏 Packet Size Distribution</h3>
|
||
<p class="text-muted">Raw packet length in bytes</p>
|
||
${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
|
||
<div class="rf-stats">
|
||
<span>Min: <strong>${rf.minPacketSize} B</strong></span>
|
||
<span>Avg: <strong>${rf.avgPacketSize} B</strong></span>
|
||
<span>Max: <strong>${rf.maxPacketSize} B</strong></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderScatter(data) {
|
||
const w = 600, h = 300, pad = 40;
|
||
const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5;
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:300px" role="img" aria-label="SNR vs RSSI scatter plot showing signal quality distribution"><title>SNR vs RSSI scatter plot showing signal quality distribution</title>`;
|
||
// Axes
|
||
svg += `<line x1="${pad}" y1="${h-pad}" x2="${w-pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||
svg += `<line x1="${pad}" y1="${pad}" x2="${pad}" y2="${h-pad}" stroke="var(--text-muted)" stroke-width="0.5"/>`;
|
||
svg += `<text x="${w/2}" y="${h-5}" text-anchor="middle" font-size="11" fill="var(--text-muted)">SNR (dB)</text>`;
|
||
svg += `<text x="12" y="${h/2}" text-anchor="middle" font-size="11" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">RSSI (dBm)</text>`;
|
||
// Grid labels
|
||
for (let snr = -10; snr <= 14; snr += 4) {
|
||
const x = pad + (snr - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${snr}</text>`;
|
||
}
|
||
for (let rssi = -120; rssi <= -20; rssi += 20) {
|
||
const y = h - pad - (rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<text x="${pad-4}" y="${y+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${rssi}</text>`;
|
||
}
|
||
// Quality zones
|
||
const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed();
|
||
const zones = [
|
||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' },
|
||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' },
|
||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' },
|
||
];
|
||
// Define patterns for color-blind accessibility
|
||
svg += `<defs>`;
|
||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="${_sg}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="${_sy}" opacity="0.4"/></pattern>`;
|
||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||
svg += `</defs>`;
|
||
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
|
||
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
|
||
const zoneBorder = { 'Excellent': _sg, 'Good': _sy, 'Weak': _sr };
|
||
zones.forEach(z => {
|
||
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const y1 = h - pad - (z.rssi[1] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
const y2 = h - pad - (z.rssi[0] - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="${z.color}"/>`;
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="url(#${zonePatterns[z.label]})"/>`;
|
||
svg += `<rect x="${x1}" y="${y1}" width="${x2-x1}" height="${y2-y1}" fill="none" stroke="${zoneBorder[z.label]}" stroke-width="1" stroke-dasharray="${zoneDash[z.label]}" opacity="0.6"/>`;
|
||
svg += `<text x="${x1+4}" y="${y1+12}" font-size="9" fill="var(--text-muted)" opacity="0.7">${z.label}</text>`;
|
||
});
|
||
// Dots (sample if too many)
|
||
const sample = data.length > 500 ? data.filter((_, i) => i % Math.ceil(data.length / 500) === 0) : data;
|
||
sample.forEach(d => {
|
||
const x = pad + (d.snr - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||
const y = h - pad - (d.rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
|
||
svg += `<circle cx="${x}" cy="${y}" r="2" fill="var(--accent)" opacity="0.5"/>`;
|
||
});
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function renderSNRByType(snrByType) {
|
||
if (!snrByType.length) return '<div class="text-muted">No data</div>';
|
||
let html = '<table class="analytics-table"><thead><tr><th scope="col">Type</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Min</th><th scope="col">Max</th><th scope="col">Distribution</th></tr></thead><tbody>';
|
||
snrByType.forEach(t => {
|
||
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
|
||
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
|
||
html += `<tr>
|
||
<td><strong>${t.name}</strong></td>
|
||
<td>${t.count}</td>
|
||
<td><strong>${sf(t.avg, 1)} dB</strong></td>
|
||
<td>${sf(t.min, 1)}</td>
|
||
<td>${sf(t.max, 1)}</td>
|
||
<td><div class="hash-bar-track" style="height:14px"><div class="hash-bar-fill" style="width:${barPct}%;background:${color};height:100%"></div></div></td>
|
||
</tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderSignalTimeline(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
const w = 400, h = 160, pad = 35;
|
||
const maxPkts = Math.max(...data.map(d => d.count), 1);
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Signal quality over time showing SNR trend and packet volume"><title>Signal quality over time showing SNR trend and packet volume</title>`;
|
||
const snrPts = data.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
svg += `<polyline points="${snrPts}" fill="none" stroke="${statusGreen()}" stroke-width="2"/>`;
|
||
// Packet count as area
|
||
const areaPts = data.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
const y = h - pad - (d.count / maxPkts) * (h - pad * 2) * 0.4;
|
||
return `${x},${y}`;
|
||
});
|
||
const baseline = data.map((_, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
return `${x},${h - pad}`;
|
||
});
|
||
svg += `<polygon points="${areaPts.join(' ')} ${baseline.reverse().join(' ')}" fill="var(--accent)" opacity="0.15"/>`;
|
||
// Labels
|
||
const step = Math.max(1, Math.floor(data.length / 6));
|
||
for (let i = 0; i < data.length; i += step) {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${data[i].hour.slice(11)}h</text>`;
|
||
}
|
||
svg += '</svg>';
|
||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:${statusGreen()}"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||
return svg;
|
||
}
|
||
|
||
// ===================== TOPOLOGY =====================
|
||
function renderTopology(el, topo) {
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🔗 Hop Count Distribution</h3>
|
||
<p class="text-muted">Number of repeater hops per packet</p>
|
||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||
<div class="rf-stats">
|
||
<span>Avg: <strong>${sf(topo.avgHops, 1)} hops</strong></span>
|
||
<span>Median: <strong>${topo.medianHops}</strong></span>
|
||
<span>Max: <strong>${topo.maxHops}</strong></span>
|
||
<span>1-hop direct: <strong>${topo.hopDistribution[0]?.count || 0}</strong></span>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>🕸️ Top Repeaters</h3>
|
||
<p class="text-muted">Nodes appearing most in packet paths</p>
|
||
${renderRepeaterTable(topo.topRepeaters)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>🤝 Repeater Pair Heatmap</h3>
|
||
<p class="text-muted">Which repeaters frequently appear together in paths</p>
|
||
${renderPairTable(topo.topPairs)}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📊 Hops vs SNR</h3>
|
||
<p class="text-muted">Does more hops = worse signal?</p>
|
||
${renderHopsSNR(topo.hopsVsSnr)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>🏆 Best Path to Each Node</h3>
|
||
<p class="text-muted">Shortest hop distance seen across all observers</p>
|
||
${renderBestPath(topo.bestPathList)}
|
||
</div>
|
||
|
||
<div class="analytics-card">
|
||
<h3>🌐 Per-Observer Reachability</h3>
|
||
<p class="text-muted">Nodes at each hop distance, from each observer's perspective</p>
|
||
${topo.observers.length > 1 ? `<div class="observer-selector" id="obsSelector">
|
||
${topo.observers.map((o, i) => `<button class="tab-btn ${i === 0 ? 'active' : ''}" data-obs="${o.id}">${esc(o.name)}</button>`).join('')}
|
||
<button class="tab-btn" data-obs="__all">All Observers</button>
|
||
</div>` : ''}
|
||
<div id="reachContent">${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}</div>
|
||
</div>
|
||
|
||
${topo.multiObsNodes.length ? `<div class="analytics-card">
|
||
<h3>🔀 Cross-Observer Comparison</h3>
|
||
<p class="text-muted">Nodes seen by multiple observers — hop distance varies by vantage point</p>
|
||
${renderCrossObserver(topo.multiObsNodes)}
|
||
</div>` : ''}
|
||
`;
|
||
|
||
// Observer selector event handling
|
||
const selector = document.getElementById('obsSelector');
|
||
if (selector) {
|
||
initTabBar(selector);
|
||
selector.addEventListener('click', e => {
|
||
const btn = e.target.closest('.tab-btn');
|
||
if (!btn) return;
|
||
selector.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const obsId = btn.dataset.obs;
|
||
document.getElementById('reachContent').innerHTML =
|
||
obsId === '__all' ? renderAllObserversReach(topo.perObserverReach) : renderPerObserverReach(topo.perObserverReach, obsId);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderRepeaterTable(repeaters) {
|
||
if (!repeaters.length) return '<div class="text-muted">No data</div>';
|
||
const max = repeaters[0].count;
|
||
let html = '<div class="repeater-list">';
|
||
repeaters.slice(0, 15).forEach(r => {
|
||
const pct = (r.count / max * 100).toFixed(0);
|
||
html += `<div class="repeater-row ${r.pubkey ? 'clickable-row' : ''}" ${r.pubkey ? `onclick="location.hash='#/nodes/${encodeURIComponent(r.pubkey)}'"` : ''}>
|
||
<div class="repeater-name">${r.name ? '<strong>' + esc(r.name) + '</strong>' : '<span class="mono">' + r.hop + '</span>'}</div>
|
||
<div class="repeater-bar"><div class="hash-bar-track"><div class="hash-bar-fill" style="width:${pct}%;background:var(--accent)"></div></div></div>
|
||
<div class="repeater-count">${r.count.toLocaleString()}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
function renderPairTable(pairs) {
|
||
if (!pairs.length) return '<div class="text-muted">Not enough multi-hop data</div>';
|
||
let html = '<table class="analytics-table"><thead><tr><th scope="col">Node A</th><th scope="col">Node B</th><th scope="col">Co-appearances</th></tr></thead><tbody>';
|
||
pairs.slice(0, 12).forEach(p => {
|
||
html += `<tr>
|
||
<td>${p.nameA ? `<a href="#/nodes/${encodeURIComponent(p.pubkeyA)}" class="analytics-link">${esc(p.nameA)}</a>` : `<span class="mono">${p.hopA}</span>`}</td>
|
||
<td>${p.nameB ? `<a href="#/nodes/${encodeURIComponent(p.pubkeyB)}" class="analytics-link">${esc(p.nameB)}</a>` : `<span class="mono">${p.hopB}</span>`}</td>
|
||
<td>${p.count}</td>
|
||
</tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderHopsSNR(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
const w = 380, h = 160, pad = 40;
|
||
const maxHop = Math.max(...data.map(d => d.hops));
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:160px" role="img" aria-label="Hops vs SNR bubble chart showing signal degradation over distance"><title>Hops vs SNR bubble chart showing signal degradation over distance</title>`;
|
||
data.forEach(d => {
|
||
const x = pad + (d.hops / maxHop) * (w - pad * 2);
|
||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
|
||
const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
|
||
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"/>`;
|
||
svg += `<text x="${x}" y="${y-r-3}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${d.hops}h</text>`;
|
||
});
|
||
svg += `<text x="${w/2}" y="${h-5}" text-anchor="middle" font-size="10" fill="var(--text-muted)">Hops</text>`;
|
||
svg += `<text x="10" y="${h/2}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,10,${h/2})">Avg SNR</text>`;
|
||
svg += '</svg>';
|
||
return svg;
|
||
}
|
||
|
||
function renderPerObserverReach(perObserverReach, obsId) {
|
||
const data = perObserverReach[obsId];
|
||
if (!data || !data.rings.length) return '<div class="text-muted">No path data for this observer</div>';
|
||
let html = `<div class="reach-rings">`;
|
||
data.rings.forEach(ring => {
|
||
const opacity = Math.max(0.3, 1 - ring.hops * 0.06);
|
||
const nodeLinks = ring.nodes.slice(0, 8).map(n => {
|
||
const label = n.name ? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>` : `<span class="mono">${n.hop}</span>`;
|
||
const detail = n.distRange ? ` <span class="text-muted">(${n.distRange})</span>` : '';
|
||
return label + detail;
|
||
}).join(', ');
|
||
const extra = ring.nodes.length > 8 ? ` <span class="text-muted">+${ring.nodes.length - 8} more</span>` : '';
|
||
html += `<div class="reach-ring" style="opacity:${opacity}">
|
||
<div class="reach-hop">${ring.hops} hop${ring.hops > 1 ? 's' : ''}</div>
|
||
<div class="reach-nodes">${nodeLinks}${extra}</div>
|
||
<div class="reach-count">${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
function renderAllObserversReach(perObserverReach) {
|
||
let html = '';
|
||
for (const [obsId, data] of Object.entries(perObserverReach)) {
|
||
html += `<h4 style="margin:12px 0 6px">📡 ${esc(data.observer_name)}</h4>`;
|
||
html += renderPerObserverReach(perObserverReach, obsId);
|
||
}
|
||
return html || '<div class="text-muted">No data</div>';
|
||
}
|
||
|
||
function renderCrossObserver(nodes) {
|
||
if (!nodes.length) return '<div class="text-muted">No nodes seen by multiple observers</div>';
|
||
let html = `<table class="analytics-table">
|
||
<thead><tr><th scope="col">Node</th><th scope="col">Observers</th><th scope="col">Hop Distances</th></tr></thead><tbody>`;
|
||
nodes.forEach(n => {
|
||
const name = n.name
|
||
? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>`
|
||
: `<span class="mono">${n.hop}</span>`;
|
||
const obsInfo = n.observers.map(o =>
|
||
`${esc(o.observer_name)}: <strong>${o.minDist} hop${o.minDist > 1 ? 's' : ''}</strong> <span class="text-muted">(${o.count} pkts)</span>`
|
||
).join('<br>');
|
||
html += `<tr><td>${name}</td><td>${n.observers.length}</td><td>${obsInfo}</td></tr>`;
|
||
});
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function renderBestPath(nodes) {
|
||
if (!nodes.length) return '<div class="text-muted">No data</div>';
|
||
// Group by distance for a cleaner view
|
||
const byDist = {};
|
||
nodes.forEach(n => {
|
||
if (!byDist[n.minDist]) byDist[n.minDist] = [];
|
||
byDist[n.minDist].push(n);
|
||
});
|
||
let html = '<div class="reach-rings">';
|
||
Object.entries(byDist).sort((a, b) => +a[0] - +b[0]).forEach(([dist, nodes]) => {
|
||
const opacity = Math.max(0.3, 1 - (+dist) * 0.06);
|
||
const nodeLinks = nodes.slice(0, 10).map(n => {
|
||
const label = n.name
|
||
? `<a href="#/nodes/${encodeURIComponent(n.pubkey)}" class="analytics-link">${esc(n.name)}</a>`
|
||
: `<span class="mono">${n.hop}</span>`;
|
||
return label + ` <span class="text-muted">via ${esc(n.observer_name)}</span>`;
|
||
}).join(', ');
|
||
const extra = nodes.length > 10 ? ` <span class="text-muted">+${nodes.length - 10} more</span>` : '';
|
||
html += `<div class="reach-ring" style="opacity:${opacity}">
|
||
<div class="reach-hop">${dist} hop${+dist > 1 ? 's' : ''}</div>
|
||
<div class="reach-nodes">${nodeLinks}${extra}</div>
|
||
<div class="reach-count">${nodes.length} node${nodes.length > 1 ? 's' : ''}</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== CHANNELS =====================
|
||
var _channelSortState = null;
|
||
var _channelData = null;
|
||
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
|
||
|
||
function loadChannelSort() {
|
||
try {
|
||
var s = localStorage.getItem(CHANNEL_SORT_KEY);
|
||
if (s) { var p = JSON.parse(s); if (p.col && p.dir) return p; }
|
||
} catch (e) {}
|
||
return { col: 'lastActivity', dir: 'desc' };
|
||
}
|
||
|
||
function saveChannelSort(state) {
|
||
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
|
||
}
|
||
|
||
function sortChannels(channels, col, dir) {
|
||
var sorted = channels.slice();
|
||
var mult = dir === 'asc' ? 1 : -1;
|
||
sorted.sort(function (a, b) {
|
||
var av, bv;
|
||
switch (col) {
|
||
case 'name':
|
||
av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase();
|
||
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
|
||
case 'hash':
|
||
av = typeof a.hash === 'number' ? a.hash : String(a.hash);
|
||
bv = typeof b.hash === 'number' ? b.hash : String(b.hash);
|
||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mult;
|
||
av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
|
||
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
|
||
case 'messages': return (a.messages - b.messages) * mult;
|
||
case 'senders': return (a.senders - b.senders) * mult;
|
||
case 'lastActivity':
|
||
av = a.lastActivity || ''; bv = b.lastActivity || '';
|
||
return av < bv ? -1 * mult : av > bv ? 1 * mult : 0;
|
||
case 'encrypted':
|
||
av = a.encrypted ? 1 : 0; bv = b.encrypted ? 1 : 0;
|
||
return (av - bv) * mult;
|
||
default: return 0;
|
||
}
|
||
});
|
||
return sorted;
|
||
}
|
||
|
||
function channelRowHtml(c) {
|
||
return '<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=' + c.hash + '" tabindex="0" role="row">' +
|
||
'<td><strong>' + esc(c.name || 'Unknown') + '</strong></td>' +
|
||
'<td class="mono">' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '</td>' +
|
||
'<td>' + c.messages + '</td>' +
|
||
'<td>' + c.senders + '</td>' +
|
||
'<td>' + timeAgo(c.lastActivity) + '</td>' +
|
||
'<td>' + (c.encrypted ? '🔒' : '✅') + '</td>' +
|
||
'</tr>';
|
||
}
|
||
|
||
function channelTbodyHtml(channels, col, dir) {
|
||
var sorted = sortChannels(channels, col, dir);
|
||
var parts = [];
|
||
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
|
||
return parts.join('');
|
||
}
|
||
|
||
function channelSortArrow(col, activeCol, dir) {
|
||
if (col !== activeCol) return '<span class="sort-arrow">⇅</span>';
|
||
return '<span class="sort-arrow">' + (dir === 'asc' ? '↑' : '↓') + '</span>';
|
||
}
|
||
|
||
function channelTheadHtml(activeCol, dir) {
|
||
var cols = [
|
||
{ key: 'name', label: 'Channel' },
|
||
{ key: 'hash', label: 'Hash' },
|
||
{ key: 'messages', label: 'Messages' },
|
||
{ key: 'senders', label: 'Unique Senders' },
|
||
{ key: 'lastActivity', label: 'Last Activity' },
|
||
{ key: 'encrypted', label: 'Decrypted' },
|
||
];
|
||
var ths = '';
|
||
for (var i = 0; i < cols.length; i++) {
|
||
var c = cols[i];
|
||
ths += '<th scope="col" class="sortable' + (c.key === activeCol ? ' sort-active' : '') + '" data-sort-col="' + c.key + '">' +
|
||
c.label + channelSortArrow(c.key, activeCol, dir) + '</th>';
|
||
}
|
||
return '<thead><tr>' + ths + '</tr></thead>';
|
||
}
|
||
|
||
function updateChannelTable() {
|
||
var tbody = document.getElementById('channelsTbody');
|
||
var thead = document.querySelector('#channelsTable thead');
|
||
if (!tbody || !_channelData) return;
|
||
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir);
|
||
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
|
||
}
|
||
|
||
function renderChannels(el, ch) {
|
||
_channelData = ch.channels;
|
||
if (!_channelSortState) _channelSortState = loadChannelSort();
|
||
|
||
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
|
||
var topSendersHtml = renderTopSenders(ch.topSenders);
|
||
var histoHtml = ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '<div class="text-muted">No decrypted messages</div>';
|
||
|
||
el.innerHTML =
|
||
'<div class="analytics-card">' +
|
||
'<h3>📻 Channel Activity</h3>' +
|
||
'<p class="text-muted">' + ch.activeChannels + ' active channels, ' + ch.decryptable + ' decryptable</p>' +
|
||
'<table class="analytics-table" id="channelsTable">' +
|
||
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
|
||
'<tbody id="channelsTbody">' +
|
||
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) +
|
||
'</tbody>' +
|
||
'</table>' +
|
||
'</div>' +
|
||
'<div class="analytics-row">' +
|
||
'<div class="analytics-card flex-1">' +
|
||
'<h3>💬 Messages / Hour by Channel</h3>' +
|
||
timelineHtml +
|
||
'</div>' +
|
||
'<div class="analytics-card flex-1">' +
|
||
'<h3>🗣️ Top Senders</h3>' +
|
||
topSendersHtml +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="analytics-card">' +
|
||
'<h3>📊 Message Length Distribution</h3>' +
|
||
histoHtml +
|
||
'</div>';
|
||
|
||
// Attach sort handler via delegation on the table
|
||
var table = document.getElementById('channelsTable');
|
||
if (table) {
|
||
table.addEventListener('click', function (e) {
|
||
var th = e.target.closest('th[data-sort-col]');
|
||
if (!th) return;
|
||
var col = th.dataset.sortCol;
|
||
if (_channelSortState.col === col) {
|
||
_channelSortState.dir = _channelSortState.dir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
_channelSortState.col = col;
|
||
_channelSortState.dir = col === 'name' || col === 'hash' ? 'asc' : 'desc';
|
||
}
|
||
saveChannelSort(_channelSortState);
|
||
updateChannelTable();
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderChannelTimeline(data) {
|
||
if (!data.length) return '<div class="text-muted">No data</div>';
|
||
var hours = []; var hourSet = {};
|
||
var channelList = []; var channelSet = {};
|
||
var lookup = {};
|
||
var maxCount = 1;
|
||
for (var i = 0; i < data.length; i++) {
|
||
var d = data[i];
|
||
if (!hourSet[d.hour]) { hourSet[d.hour] = 1; hours.push(d.hour); }
|
||
if (!channelSet[d.channel]) { channelSet[d.channel] = 1; channelList.push(d.channel); }
|
||
lookup[d.hour + '|' + d.channel] = d.count;
|
||
if (d.count > maxCount) maxCount = d.count;
|
||
}
|
||
hours.sort();
|
||
var colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b'];
|
||
var w = 600, h = 180, pad = 35;
|
||
var xScale = (w - pad * 2) / Math.max(hours.length - 1, 1);
|
||
var yScale = (h - pad * 2) / maxCount;
|
||
var svg = '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:100%;max-height:180px" role="img" aria-label="Channel message activity over time"><title>Channel message activity over time</title>';
|
||
for (var ci = 0; ci < channelList.length; ci++) {
|
||
var pts = [];
|
||
for (var hi = 0; hi < hours.length; hi++) {
|
||
var count = lookup[hours[hi] + '|' + channelList[ci]] || 0;
|
||
var x = pad + hi * xScale;
|
||
var y = h - pad - count * yScale;
|
||
pts.push(x + ',' + y);
|
||
}
|
||
svg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + colors[ci % colors.length] + '" stroke-width="1.5" opacity="0.8"/>';
|
||
}
|
||
var step = Math.max(1, Math.floor(hours.length / 6));
|
||
for (var li = 0; li < hours.length; li += step) {
|
||
var lx = pad + li * xScale;
|
||
svg += '<text x="' + lx + '" y="' + (h - pad + 14) + '" text-anchor="middle" font-size="9" fill="var(--text-muted)">' + hours[li].slice(11) + 'h</text>';
|
||
}
|
||
svg += '</svg>';
|
||
var legendParts = [];
|
||
for (var lci = 0; lci < channelList.length; lci++) {
|
||
legendParts.push('<span><span class="legend-dot" style="background:' + colors[lci % colors.length] + '"></span>' + esc(channelList[lci]) + '</span>');
|
||
}
|
||
svg += '<div class="timeline-legend">' + legendParts.join('') + '</div>';
|
||
return svg;
|
||
}
|
||
|
||
function renderTopSenders(senders) {
|
||
if (!senders.length) return '<div class="text-muted">No decrypted messages</div>';
|
||
const max = senders[0].count;
|
||
let html = '<div class="repeater-list">';
|
||
senders.slice(0, 10).forEach(s => {
|
||
html += `<div class="repeater-row">
|
||
<div class="repeater-name"><strong>${esc(s.name)}</strong></div>
|
||
<div class="repeater-bar"><div class="hash-bar-track"><div class="hash-bar-fill" style="width:${(s.count/max*100).toFixed(0)}%;background:#8b5cf6"></div></div></div>
|
||
<div class="repeater-count">${s.count} msgs</div>
|
||
</div>`;
|
||
});
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ===================== HASH SIZES (original) =====================
|
||
function renderHashSizes(el, data) {
|
||
const d = data.distribution;
|
||
const total = data.total;
|
||
const pct = (n) => total ? (n / total * 100).toFixed(1) : '0';
|
||
const maxCount = Math.max(d[1] || 0, d[2] || 0, d[3] || 0, 1);
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>Hash Size Distribution</h3>
|
||
<p class="text-muted">${total.toLocaleString()} packets with path hops</p>
|
||
<div class="hash-bars">
|
||
${[1, 2, 3].map(size => {
|
||
const count = d[size] || 0;
|
||
const width = Math.max((count / maxCount) * 100, count ? 2 : 0);
|
||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||
return `<div class="hash-bar-row">
|
||
<div class="hash-bar-label"><strong>${size}-byte</strong> <span class="text-muted">(${size * 8}-bit, ${Math.pow(256, size).toLocaleString()} IDs)</span></div>
|
||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]}"></div></div>
|
||
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${pct(count)}%)</span></div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
${data.distributionByRepeaters ? (() => {
|
||
const dr = data.distributionByRepeaters;
|
||
const totalRepeaters = (dr[1] || 0) + (dr[2] || 0) + (dr[3] || 0);
|
||
const rpct = (n) => totalRepeaters ? (n / totalRepeaters * 100).toFixed(1) : '0';
|
||
const maxRepeaters = Math.max(dr[1] || 0, dr[2] || 0, dr[3] || 0, 1);
|
||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||
return `<h4 style="margin:16px 0 4px">By Repeaters</h4>
|
||
<p class="text-muted">${totalRepeaters.toLocaleString()} unique repeaters</p>
|
||
<div class="hash-bars">
|
||
${[1, 2, 3].map(size => {
|
||
const count = dr[size] || 0;
|
||
const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0);
|
||
return `<div class="hash-bar-row">
|
||
<div class="hash-bar-label"><strong>${size}-byte</strong></div>
|
||
<div class="hash-bar-track"><div class="hash-bar-fill" style="width:${width}%;background:${colors[size]};opacity:0.7"></div></div>
|
||
<div class="hash-bar-value">${count.toLocaleString()} <span class="text-muted">(${rpct(count)}%)</span></div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
})() : ''}
|
||
</div>
|
||
<div class="analytics-card flex-1">
|
||
<h3>📈 Hash Size Over Time</h3>
|
||
${renderHashTimeline(data.hourly)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="analytics-row">
|
||
<div class="analytics-card flex-1">
|
||
<h3>Multi-Byte Hash Adopters</h3>
|
||
<p class="text-muted">Nodes advertising with 2+ byte hash paths</p>
|
||
${data.multiByteNodes.length ? `
|
||
<table class="analytics-table">
|
||
<thead><tr><th scope="col">Node</th><th scope="col">Hash Size</th><th scope="col">Adverts</th><th scope="col">Last Seen</th></tr></thead>
|
||
<tbody>
|
||
${data.multiByteNodes.map(n => `<tr class="clickable-row" data-action="navigate" data-value="#/nodes/${n.pubkey ? encodeURIComponent(n.pubkey) : ''}" tabindex="0" role="row">
|
||
<td><strong>${esc(n.name)}</strong></td>
|
||
<td><span class="badge badge-hash-${n.hashSize}">${n.hashSize}-byte</span></td>
|
||
<td>${n.packets}</td>
|
||
<td>${timeAgo(n.lastSeen)}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
` : '<div class="text-muted" style="padding:16px">No multi-byte adopters found</div>'}
|
||
</div>
|
||
|
||
<div class="analytics-card flex-1">
|
||
<h3>Top Path Hops</h3>
|
||
<table class="analytics-table">
|
||
<thead><tr><th scope="col">Hop</th><th scope="col">Node</th><th scope="col">Bytes</th><th scope="col">Appearances</th></tr></thead>
|
||
<tbody>
|
||
${data.topHops.map(h => {
|
||
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
|
||
return `<tr class="clickable-row" data-action="navigate" data-value="${link}" tabindex="0" role="row">
|
||
<td class="mono">${h.hex}</td>
|
||
<td>${h.name ? `<strong>${esc(h.name)}</strong>` : '<span class="text-muted">unknown</span>'}</td>
|
||
<td><span class="badge badge-hash-${h.size}">${h.size}-byte</span></td>
|
||
<td>${h.count.toLocaleString()}</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renderCollisionTab(el, data) {
|
||
el.innerHTML = `
|
||
<nav id="hashIssuesToc" style="display:flex;gap:12px;margin-bottom:12px;font-size:13px;flex-wrap:wrap">
|
||
<a href="#/analytics?tab=collisions§ion=inconsistentHashSection" style="color:var(--accent)">⚠️ Inconsistent Sizes</a>
|
||
<span style="color:var(--border)">|</span>
|
||
<a href="#/analytics?tab=collisions§ion=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
|
||
<span style="color:var(--border)">|</span>
|
||
<a href="#/analytics?tab=collisions§ion=collisionRiskSection" style="color:var(--accent)">💥 Collision Risk</a>
|
||
</nav>
|
||
|
||
<div class="analytics-card" id="inconsistentHashSection">
|
||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">⚠️ Inconsistent Hash Sizes</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Nodes sending adverts with varying hash sizes. Caused by a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">bug</a> where automatic adverts ignored the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</p>
|
||
<div id="inconsistentHashList"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading…</div></div>
|
||
</div>
|
||
|
||
<div class="analytics-card" id="hashMatrixSection">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
|
||
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
|
||
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
|
||
<button class="hash-byte-btn active" data-bytes="1">1-Byte</button>
|
||
<button class="hash-byte-btn" data-bytes="2">2-Byte</button>
|
||
<button class="hash-byte-btn" data-bytes="3">3-Byte</button>
|
||
</div>
|
||
<p class="text-muted" id="hashMatrixDesc" style="margin:0;font-size:0.8em">Click a cell to see which nodes share that prefix.</p>
|
||
</div>
|
||
<div id="hashMatrix"></div>
|
||
</div>
|
||
|
||
<div class="analytics-card" id="collisionRiskSection">
|
||
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
|
||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||
</div>
|
||
`;
|
||
let allNodes = [];
|
||
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||
|
||
// Render inconsistent hash sizes
|
||
const inconsistent = allNodes.filter(n => n.hash_size_inconsistent);
|
||
const ihEl = document.getElementById('inconsistentHashList');
|
||
if (ihEl) {
|
||
if (!inconsistent.length) {
|
||
ihEl.innerHTML = '<div class="text-muted" style="padding:4px">✅ No inconsistencies detected — all nodes are reporting consistent hash sizes.</div>';
|
||
} else {
|
||
ihEl.innerHTML = `<table class="analytics-table" style="background:var(--card-bg);border:1px solid var(--border);border-radius:8px;overflow:hidden">
|
||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Current Hash</th><th scope="col">Sizes Seen</th></tr></thead>
|
||
<tbody>${inconsistent.map((n, i) => {
|
||
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
|
||
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
|
||
const sizeBadges = (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).map(s => {
|
||
const c = s >= 3 ? '#16a34a' : s === 2 ? '#86efac' : '#f97316';
|
||
const fg = s === 2 ? '#064e3b' : '#fff';
|
||
return '<span class="badge" style="background:' + c + ';color:' + fg + ';font-size:10px;font-family:var(--mono)">' + s + 'B</span>';
|
||
}).join(' ');
|
||
const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : '';
|
||
return `<tr style="${stripe}">
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}?section=node-packets" style="font-weight:600;color:var(--accent)">${esc(n.name || n.public_key.slice(0, 12))}</a></td>
|
||
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
|
||
<td><code style="font-family:var(--mono);font-weight:700">${prefix}</code> <span class="text-muted">(${n.hash_size || '?'}B)</span></td>
|
||
<td>${sizeBadges}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
<p class="text-muted" style="margin:8px 0 0;font-size:0.8em">${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.</p>`;
|
||
}
|
||
}
|
||
|
||
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
|
||
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
|
||
const nullRoleNodes = allNodes.filter(n => !n.role);
|
||
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
|
||
|
||
let currentBytes = 1;
|
||
function refreshHashViews(bytes) {
|
||
currentBytes = bytes;
|
||
hideMatrixTip();
|
||
// Update selector button states
|
||
document.querySelectorAll('.hash-byte-btn').forEach(b => {
|
||
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
|
||
});
|
||
// Update titles and description
|
||
const matrixTitle = document.getElementById('hashMatrixTitle');
|
||
const matrixDesc = document.getElementById('hashMatrixDesc');
|
||
const riskTitle = document.getElementById('collisionRiskTitle');
|
||
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
|
||
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
|
||
if (matrixDesc) {
|
||
if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.';
|
||
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
|
||
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
|
||
}
|
||
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
|
||
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
|
||
const riskCard = document.getElementById('collisionRiskSection');
|
||
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
|
||
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
|
||
}
|
||
|
||
// Wire up selector
|
||
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
|
||
});
|
||
|
||
refreshHashViews(1);
|
||
}
|
||
|
||
function renderHashTimeline(hourly) {
|
||
if (!hourly.length) return '<div class="text-muted">Not enough data</div>';
|
||
const w = 800, h = 180, pad = 35;
|
||
const maxVal = Math.max(...hourly.map(h => Math.max(h[1] || 0, h[2] || 0, h[3] || 0)), 1);
|
||
const colors = { 1: '#ef4444', 2: '#22c55e', 3: '#3b82f6' };
|
||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:180px" role="img" aria-label="Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends"><title>Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends</title>`;
|
||
for (const size of [1, 2, 3]) {
|
||
const pts = hourly.map((d, i) => {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
|
||
const y = h - pad - ((d[size] || 0) / maxVal) * (h - pad * 2);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
if (hourly.some(d => d[size] > 0)) svg += `<polyline points="${pts}" fill="none" stroke="${colors[size]}" stroke-width="2"/>`;
|
||
}
|
||
const step = Math.max(1, Math.floor(hourly.length / 8));
|
||
for (let i = 0; i < hourly.length; i += step) {
|
||
const x = pad + i * ((w - pad * 2) / Math.max(hourly.length - 1, 1));
|
||
svg += `<text x="${x}" y="${h-5}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${hourly[i].hour.slice(11)}h</text>`;
|
||
}
|
||
svg += '</svg>';
|
||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#ef4444"></span>1-byte</span><span><span class="legend-dot" style="background:#22c55e"></span>2-byte</span><span><span class="legend-dot" style="background:#3b82f6"></span>3-byte</span></div>`;
|
||
return svg;
|
||
}
|
||
|
||
// Shared hover tooltip for hash matrix cells.
|
||
// Called once per container — reads content from data-tip on each <td>.
|
||
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
|
||
let _matrixTip = null;
|
||
function getMatrixTip() {
|
||
if (!_matrixTip) {
|
||
_matrixTip = document.createElement('div');
|
||
_matrixTip.className = 'hash-matrix-tooltip';
|
||
_matrixTip.style.display = 'none';
|
||
document.body.appendChild(_matrixTip);
|
||
}
|
||
return _matrixTip;
|
||
}
|
||
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
|
||
|
||
function initMatrixTooltip(el) {
|
||
if (el._matrixTipInit) return;
|
||
el._matrixTipInit = true;
|
||
el.addEventListener('mouseover', e => {
|
||
const td = e.target.closest('td[data-tip]');
|
||
if (!td) return;
|
||
const tip = getMatrixTip();
|
||
tip.innerHTML = td.dataset.tip;
|
||
tip.style.display = 'block';
|
||
});
|
||
el.addEventListener('mousemove', e => {
|
||
if (!_matrixTip || _matrixTip.style.display === 'none') return;
|
||
const x = e.clientX + 14, y = e.clientY + 14;
|
||
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
|
||
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
|
||
});
|
||
el.addEventListener('mouseout', e => {
|
||
if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip();
|
||
});
|
||
el.addEventListener('mouseleave', hideMatrixTip);
|
||
}
|
||
|
||
// Pure data helpers — extracted for testability
|
||
|
||
function buildOneBytePrefixMap(nodes) {
|
||
const map = {};
|
||
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
|
||
for (const n of nodes) {
|
||
const hex = n.public_key.slice(0, 2).toUpperCase();
|
||
if (map[hex]) map[hex].push(n);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function buildTwoBytePrefixInfo(nodes) {
|
||
const info = {};
|
||
for (let i = 0; i < 256; i++) {
|
||
const h = i.toString(16).padStart(2, '0').toUpperCase();
|
||
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
|
||
}
|
||
for (const n of nodes) {
|
||
const firstHex = n.public_key.slice(0, 2).toUpperCase();
|
||
const twoHex = n.public_key.slice(0, 4).toUpperCase();
|
||
const entry = info[firstHex];
|
||
if (!entry) continue;
|
||
entry.groupNodes.push(n);
|
||
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
|
||
entry.twoByteMap[twoHex].push(n);
|
||
}
|
||
for (const entry of Object.values(info)) {
|
||
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
|
||
entry.collisionCount = collisions.length;
|
||
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
|
||
}
|
||
return info;
|
||
}
|
||
|
||
function buildCollisionHops(allNodes, bytes) {
|
||
const map = {};
|
||
for (const n of allNodes) {
|
||
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
|
||
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
|
||
map[p].count++;
|
||
}
|
||
return Object.values(map).filter(h => h.count > 1);
|
||
}
|
||
|
||
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
|
||
bytes = bytes || 1;
|
||
totalNodes = totalNodes || allNodes;
|
||
const el = document.getElementById('hashMatrix');
|
||
|
||
// 3-byte: show a summary panel instead of a matrix
|
||
if (bytes === 3) {
|
||
const total = totalNodes.length;
|
||
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
|
||
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
|
||
const prefixMap = {};
|
||
for (const n of nodesForByte) {
|
||
const p = n.public_key.slice(0, 6).toUpperCase();
|
||
if (!prefixMap[p]) prefixMap[p] = 0;
|
||
prefixMap[p]++;
|
||
}
|
||
const uniquePrefixes = Object.keys(prefixMap).length;
|
||
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
|
||
const spaceSize = 16777216; // 2^24
|
||
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
|
||
el.innerHTML = `
|
||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Nodes tracked</div>
|
||
<div class="analytics-stat-value">${total.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Using 3-byte ID</div>
|
||
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Prefix space used</div>
|
||
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
|
||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||
<div class="analytics-stat-label">Prefix collisions</div>
|
||
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
|
||
return;
|
||
}
|
||
|
||
const nibbles = '0123456789ABCDEF'.split('');
|
||
const cellSize = 36;
|
||
const headerSize = 24;
|
||
|
||
if (bytes === 1) {
|
||
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
|
||
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
|
||
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
|
||
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
|
||
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
|
||
const onePct = ((oneUsed / 256) * 100).toFixed(1);
|
||
|
||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Nodes tracked</div>
|
||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Using 1-byte ID</div>
|
||
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Prefix space used</div>
|
||
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
|
||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||
<div class="analytics-stat-label">Prefix collisions</div>
|
||
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
|
||
</div>
|
||
</div>`;
|
||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||
html += '</tr>';
|
||
for (let hi = 0; hi < 16; hi++) {
|
||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||
for (let lo = 0; lo < 16; lo++) {
|
||
const hex = nibbles[hi] + nibbles[lo];
|
||
const nodes = prefixNodes[hex] || [];
|
||
const count = nodes.length;
|
||
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
|
||
const isCollision = count >= 2 && repeaterCount >= 2;
|
||
const isPossible = count >= 2 && !isCollision;
|
||
let cellClass, bgStyle;
|
||
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
|
||
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
|
||
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
|
||
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
|
||
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
|
||
const tip1 = count === 0
|
||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
|
||
: count === 1
|
||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
|
||
: isPossible
|
||
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
|
||
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
|
||
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</table></div>';
|
||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
|
||
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
|
||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||
</div>`;
|
||
el.innerHTML = html;
|
||
|
||
initMatrixTooltip(el);
|
||
|
||
el.querySelectorAll('.hash-active').forEach(td => {
|
||
td.addEventListener('click', () => {
|
||
const hex = td.dataset.hex.toUpperCase();
|
||
const matches = prefixNodes[hex] || [];
|
||
const detail = document.getElementById('hashDetail');
|
||
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
|
||
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
|
||
`<div style="margin-top:8px">${matches.map(m => {
|
||
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>` : '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
|
||
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
|
||
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
|
||
}).join('')}</div>`;
|
||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||
td.classList.add('hash-selected');
|
||
});
|
||
});
|
||
|
||
} else if (bytes === 2) {
|
||
// 2-byte mode: 16×16 grid of first-byte groups
|
||
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
|
||
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
|
||
|
||
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
|
||
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
|
||
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
|
||
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
|
||
|
||
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Nodes tracked</div>
|
||
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Using 2-byte ID</div>
|
||
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||
<div class="analytics-stat-label">Prefix space used</div>
|
||
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
|
||
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
|
||
<div class="analytics-stat-label">Prefix collisions</div>
|
||
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
|
||
</div>
|
||
</div>`;
|
||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
|
||
html += `<tr><td style="width:${headerSize}px"></td>`;
|
||
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
|
||
html += '</tr>';
|
||
for (let hi = 0; hi < 16; hi++) {
|
||
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
|
||
for (let lo = 0; lo < 16; lo++) {
|
||
const hex = nibbles[hi] + nibbles[lo];
|
||
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
|
||
const nodeCount = info.groupNodes.length;
|
||
const maxCol = info.maxCollision;
|
||
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
|
||
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
|
||
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
|
||
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
|
||
let cellClass2, bgStyle2;
|
||
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
|
||
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
|
||
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
|
||
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
|
||
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
|
||
const tip2 = nodeCount === 0
|
||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
|
||
: info.collisionCount === 0
|
||
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
|
||
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
|
||
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'"')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</table></div>';
|
||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
|
||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
|
||
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
|
||
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
|
||
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
|
||
</div>`;
|
||
el.innerHTML = html;
|
||
|
||
el.querySelectorAll('.hash-active').forEach(td => {
|
||
td.addEventListener('click', () => {
|
||
const hex = td.dataset.hex.toUpperCase();
|
||
const info = firstByteInfo[hex];
|
||
const detail = document.getElementById('hashDetail');
|
||
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
|
||
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
|
||
if (info.collisionCount === 0) {
|
||
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
|
||
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
|
||
const prefix = m.public_key.slice(0,4).toUpperCase();
|
||
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
|
||
}).join('')}</div>`;
|
||
} else {
|
||
dhtml += `<div style="margin-top:8px">`;
|
||
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
|
||
const isCollision = nodes.length > 1;
|
||
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
|
||
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
|
||
dhtml += nodes.map(m => `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link" style="font-size:0.85em">${esc(m.name || m.public_key.slice(0,12))}</a>`).join(', ');
|
||
dhtml += `</div>`;
|
||
}
|
||
dhtml += '</div>';
|
||
}
|
||
detail.innerHTML = dhtml;
|
||
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
|
||
td.classList.add('hash-selected');
|
||
});
|
||
});
|
||
|
||
initMatrixTooltip(el);
|
||
}
|
||
}
|
||
|
||
async function renderCollisions(topHops, allNodes, bytes) {
|
||
bytes = bytes || 1;
|
||
const el = document.getElementById('collisionList');
|
||
const hopsForSize = topHops.filter(h => h.size === bytes);
|
||
|
||
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
|
||
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
|
||
|
||
if (!hopsToCheck.length && bytes === 1) {
|
||
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
|
||
return;
|
||
}
|
||
try {
|
||
const nodes = allNodes;
|
||
const collisions = [];
|
||
for (const hop of hopsToCheck) {
|
||
const prefix = hop.hex.toLowerCase();
|
||
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
|
||
if (matches.length > 1) {
|
||
// Calculate pairwise distances for classification
|
||
const withCoords = matches.filter(m => m.lat && m.lon && !(m.lat === 0 && m.lon === 0));
|
||
let maxDistKm = 0;
|
||
let classification = 'unknown';
|
||
if (withCoords.length >= 2) {
|
||
for (let i = 0; i < withCoords.length; i++) {
|
||
for (let j = i + 1; j < withCoords.length; j++) {
|
||
const dLat = (withCoords[i].lat - withCoords[j].lat) * 111;
|
||
const dLon = (withCoords[i].lon - withCoords[j].lon) * 85;
|
||
const d = Math.sqrt(dLat * dLat + dLon * dLon);
|
||
if (d > maxDistKm) maxDistKm = d;
|
||
}
|
||
}
|
||
if (maxDistKm < 50) classification = 'local';
|
||
else if (maxDistKm < 200) classification = 'regional';
|
||
else classification = 'distant';
|
||
} else if (withCoords.length < 2) {
|
||
classification = 'incomplete';
|
||
}
|
||
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
|
||
}
|
||
}
|
||
if (!collisions.length) {
|
||
const cleanMsg = bytes === 3
|
||
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
|
||
: `✅ No ${bytes}-byte collisions detected`;
|
||
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
|
||
return;
|
||
}
|
||
|
||
// Sort: local first (most likely to collide), then regional, distant, incomplete
|
||
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
|
||
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
|
||
|
||
const showAppearances = bytes < 3;
|
||
el.innerHTML = `<table class="analytics-table">
|
||
<thead><tr>
|
||
<th scope="col">Prefix</th>
|
||
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
|
||
<th scope="col">Max Distance</th>
|
||
<th scope="col">Assessment</th>
|
||
<th scope="col">Colliding Nodes</th>
|
||
</tr></thead>
|
||
<tbody>${collisions.map(c => {
|
||
let badge, tooltip;
|
||
if (c.classification === 'local') {
|
||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||
} else if (c.classification === 'regional') {
|
||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||
} else if (c.classification === 'distant') {
|
||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||
} else {
|
||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||
tooltip = 'Not enough coordinate data to classify';
|
||
}
|
||
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
|
||
return `<tr>
|
||
<td class="mono">${c.hop}</td>
|
||
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
|
||
<td>${distStr}</td>
|
||
<td title="${tooltip}">${badge}</td>
|
||
<td>${c.matches.map(m => {
|
||
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
|
||
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
|
||
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
|
||
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
|
||
}).join('<br>')}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
<div class="text-muted" style="padding:8px;font-size:0.8em">
|
||
<strong>🏘️ Local</strong> <50km: true prefix collision, same mesh area
|
||
<strong>⚡ Regional</strong> 50–200km: edge of LoRa range, possible atmospheric propagation
|
||
<strong>🌐 Distant</strong> >200km: beyond 915MHz range — internet bridge, MQTT gateway, or separate networks
|
||
</div>`;
|
||
} catch { el.innerHTML = '<div class="text-muted">Failed to load</div>'; }
|
||
}
|
||
|
||
async function renderSubpaths(el) {
|
||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||
try {
|
||
const rq = RegionFilter.regionQueryString();
|
||
const [d2, d3, d4, d5] = await Promise.all([
|
||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
|
||
]);
|
||
|
||
function renderTable(data, title) {
|
||
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
|
||
const maxCount = data.subpaths[0]?.count || 1;
|
||
return `<h4>${title}</h4>
|
||
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
|
||
<table class="analytics-table"><thead><tr>
|
||
<th scope="col">#</th><th scope="col">Route</th><th scope="col">Occurrences</th><th scope="col">% of paths</th><th scope="col">Frequency</th>
|
||
</tr></thead><tbody>
|
||
${data.subpaths.map((s, i) => {
|
||
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
|
||
const hops = s.path.split(' → ');
|
||
const rawHops = s.rawHops || [];
|
||
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 `<tr data-hops="${esc(rawHops.join(','))}" ${hasSelfLoop ? 'class="subpath-selfloop"' : ''} style="cursor:pointer">
|
||
<td>${i + 1}</td>
|
||
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
|
||
<td>${s.count.toLocaleString()}</td>
|
||
<td>${s.pct}%</td>
|
||
<td><div style="background:${hasSelfLoop ? 'var(--status-yellow)' : 'var(--accent)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="subpath-layout">
|
||
<div class="subpath-list" id="subpathList">
|
||
<h3>🛤️ Route Pattern Analysis</h3>
|
||
<p>Click a route to see details. Most common subpaths — reveals backbone routes, bottlenecks, and preferred relay chains.</p>
|
||
<label style="display:inline-flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;font-size:0.9em">
|
||
<input type="checkbox" id="hideCollisions" aria-label="Hide likely prefix collisions" ${localStorage.getItem('subpath-hide-collisions') === '1' ? 'checked' : ''}> Hide likely prefix collisions (self-loops)
|
||
</label>
|
||
<div class="subpath-jump-nav">
|
||
<span>Jump to:</span>
|
||
<a href="#sp-pairs">Pairs</a>
|
||
<a href="#sp-triples">Triples</a>
|
||
<a href="#sp-quads">Quads</a>
|
||
<a href="#sp-long">5+ hops</a>
|
||
</div>
|
||
<div id="sp-pairs">${renderTable(d2, 'Pairs (2-hop links)')}</div>
|
||
<div id="sp-triples">${renderTable(d3, 'Triples (3-hop chains)')}</div>
|
||
<div id="sp-quads">${renderTable(d4, 'Quads (4-hop chains)')}</div>
|
||
<div id="sp-long">${renderTable(d5, 'Long chains (5+ hops)')}</div>
|
||
</div>
|
||
<div class="subpath-detail collapsed" id="subpathDetail">
|
||
<div class="text-muted" style="padding:40px;text-align:center">Select a route to view details</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Jump nav — scroll within list panel
|
||
el.querySelectorAll('.subpath-jump-nav a').forEach(a => {
|
||
a.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
const target = document.getElementById(a.getAttribute('href').slice(1));
|
||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
});
|
||
|
||
// Collision toggle
|
||
const toggle = document.getElementById('hideCollisions');
|
||
function applyCollisionFilter() {
|
||
const hide = toggle.checked;
|
||
localStorage.setItem('subpath-hide-collisions', hide ? '1' : '0');
|
||
el.querySelectorAll('tr.subpath-selfloop').forEach(r => r.style.display = hide ? 'none' : '');
|
||
}
|
||
toggle.addEventListener('change', applyCollisionFilter);
|
||
applyCollisionFilter();
|
||
} catch (e) {
|
||
el.innerHTML = `<div class="text-muted">Error loading subpath data: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadSubpathDetail(hopsStr) {
|
||
const panel = document.getElementById('subpathDetail');
|
||
panel.classList.remove('collapsed');
|
||
panel.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading…</div>';
|
||
try {
|
||
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
|
||
renderSubpathDetail(panel, data);
|
||
} catch (e) {
|
||
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<div class="subpath-detail-inner">
|
||
<h4>${data.nodes.map(n => esc(n.name)).join(' → ')}</h4>
|
||
<div class="subpath-meta">
|
||
<span class="hop-prefix mono">${data.hops.join(' → ')}</span>
|
||
<span>${data.totalMatches.toLocaleString()} occurrences</span>
|
||
</div>
|
||
|
||
${nodesWithLoc.length >= 2 ? `<div class="subpath-section">
|
||
<h5>📏 Hop Distances</h5>
|
||
${(() => {
|
||
const dists = [];
|
||
let total = 0;
|
||
for (let i = 0; i < data.nodes.length - 1; i++) {
|
||
const a = data.nodes[i], b = data.nodes[i+1];
|
||
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
|
||
const dLat = (a.lat - b.lat) * 111;
|
||
const dLon = (a.lon - b.lon) * 85;
|
||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||
total += km;
|
||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||
} else {
|
||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||
}
|
||
}
|
||
if (dists.length > 1) dists.push(`<div style="padding:4px 0;border-top:1px solid var(--border);margin-top:4px"><strong>Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}</strong></div>`);
|
||
return dists.join('');
|
||
})()}
|
||
</div>` : ''}
|
||
|
||
${hasMap ? '<div id="subpathMap" style="height:200px;border-radius:8px;margin:12px 0;border:1px solid var(--border,#e5e7eb)"></div>' : ''}
|
||
|
||
<div class="subpath-section">
|
||
<h5>📡 Observer Receive Signal</h5>
|
||
<p class="text-muted" style="font-size:0.8em;margin:0 0 4px">Last hop → observer only, not between nodes in the route</p>
|
||
${data.signal.avgSnr != null
|
||
? `<div>Avg SNR: <strong>${data.signal.avgSnr} dB</strong> · Avg RSSI: <strong>${data.signal.avgRssi} dBm</strong> · ${data.signal.samples} samples</div>`
|
||
: '<div class="text-muted">No signal data</div>'}
|
||
</div>
|
||
|
||
<div class="subpath-section">
|
||
<h5>🕐 Activity by Hour (UTC)</h5>
|
||
<div class="hour-chart">
|
||
${data.hourDistribution.map((c, h) => `<div class="hour-bar" title="${h}:00 UTC — ${c} packets" style="height:${Math.max(2, c / maxHour * 100)}%"></div>`).join('')}
|
||
</div>
|
||
<div class="hour-labels"><span>0</span><span>6</span><span>12</span><span>18</span><span>23</span></div>
|
||
</div>
|
||
|
||
<div class="subpath-section">
|
||
<h5>⏱️ Timeline</h5>
|
||
<div>First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : '—'}</div>
|
||
<div>Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : '—'}</div>
|
||
</div>
|
||
|
||
${data.observers.length ? `
|
||
<div class="subpath-section">
|
||
<h5>👁️ Observers</h5>
|
||
${data.observers.map(o => `<div>${esc(o.name)}: ${o.count}</div>`).join('')}
|
||
</div>` : ''}
|
||
|
||
${data.parentPaths.length ? `
|
||
<div class="subpath-section">
|
||
<h5>🔗 Full Paths Containing This Route</h5>
|
||
<div class="parent-paths">
|
||
${data.parentPaths.map(p => `<div class="parent-path"><span class="mono" style="font-size:0.85em">${esc(p.path)}</span> <span class="text-muted">×${p.count}</span></div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
</div>`;
|
||
|
||
// Render minimap
|
||
if (hasMap && typeof L !== 'undefined') {
|
||
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
|
||
L.tileLayer(getTileUrl(), { 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 ? statusGreen() : statusRed()) : statusYellow(),
|
||
fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
|
||
fillOpacity: 0.9, weight: 2
|
||
}).bindTooltip(n.name, { permanent: false }).addTo(map);
|
||
});
|
||
|
||
L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
|
||
}
|
||
}
|
||
|
||
async function renderNodesTab(el) {
|
||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||
try {
|
||
const rq = RegionFilter.regionQueryString();
|
||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
|
||
]);
|
||
const nodes = nodesResp.nodes || nodesResp;
|
||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||
const myKeys = new Set(myNodes.map(n => n.pubkey));
|
||
|
||
// Map bulk health by pubkey
|
||
const healthMap = {};
|
||
bulkHealth.forEach(h => { healthMap[h.public_key] = h; });
|
||
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
|
||
|
||
// Compute rankings
|
||
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0));
|
||
const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr);
|
||
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
|
||
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
|
||
|
||
// Use server-computed status across ALL nodes
|
||
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
|
||
|
||
function nodeLink(n) {
|
||
return `<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">${esc(n.name || n.public_key.slice(0, 12))}</a>`;
|
||
}
|
||
function claimedBadge(n) {
|
||
return myKeys.has(n.public_key) ? ' <span style="color:var(--accent);font-size:10px">★ MINE</span>' : '';
|
||
}
|
||
|
||
// ROLE_COLORS from shared roles.js
|
||
|
||
el.innerHTML = `
|
||
<div class="analytics-section">
|
||
<h3>🔍 Network Status</h3>
|
||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:var(--status-green)">${active}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:var(--status-yellow)">${degraded}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700;color:var(--status-red)">${silent}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
|
||
</div>
|
||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||
<div style="font-size:28px;font-weight:700">${totalNodes}</div>
|
||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">Total Nodes</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h3>📊 Role Breakdown</h3>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px">
|
||
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
|
||
const c = ROLE_COLORS[role] || '#6b7280';
|
||
return `<span class="badge" style="background:${c}20;color:${c};padding:6px 12px;font-size:13px">${role}: ${count}</span>`;
|
||
}).join('')}
|
||
</div>
|
||
|
||
${myKeys.size ? `<h3>⭐ My Claimed Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Packets</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Last Heard</th></tr></thead>
|
||
<tbody>
|
||
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
|
||
const s = n.health.stats;
|
||
return `<tr>
|
||
<td>${nodeLink(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${s.totalTransmissions || s.totalPackets || 0}</td>
|
||
<td>${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td>${s.lastHeard ? timeAgo(s.lastHeard) : '—'}</td>
|
||
</tr>`;
|
||
}).join('') || '<tr><td colspan="6" class="text-muted">No claimed nodes have health data</td></tr>'}
|
||
</tbody>
|
||
</table>` : ''}
|
||
|
||
<h3>🏆 Most Active Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Total Packets</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byPackets.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}</td>
|
||
<td>${n.health.stats.packetsToday || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>📶 Best Signal Quality</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Avg SNR</th><th scope="col">Observers</th><th scope="col">Analytics</th></tr></thead>
|
||
<tbody>
|
||
${bySnr.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.stats.avgSnr.toFixed(1)} dB</td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>👀 Most Observed Nodes</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th scope="col">#</th><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Observers</th><th scope="col">Avg SNR</th><th scope="col">Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byObservers.slice(0, 15).map((n, i) => `<tr>
|
||
<td>${i + 1}</td>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${n.health.observers?.length || 0}</td>
|
||
<td>${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : '—'}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>⏰ Recently Active</h3>
|
||
<table class="analytics-table" style="margin-bottom:24px">
|
||
<thead><tr><th scope="col">Node</th><th scope="col">Role</th><th scope="col">Last Heard</th><th scope="col">Packets Today</th><th scope="col">Analytics</th></tr></thead>
|
||
<tbody>
|
||
${byRecent.slice(0, 15).map(n => `<tr>
|
||
<td>${nodeLink(n)}${claimedBadge(n)}</td>
|
||
<td><span class="badge" style="background:${(ROLE_COLORS[n.role]||'#6b7280')}20;color:${ROLE_COLORS[n.role]||'#6b7280'}">${n.role}</span></td>
|
||
<td>${timeAgo(n.health.stats.lastHeard)}</td>
|
||
<td>${n.health.stats.packetsToday || 0}</td>
|
||
<td><a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="analytics-link">📊</a></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) {
|
||
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load node analytics: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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 scope="col">Type</th><th scope="col">Count</th><th scope="col">Avg (km)</th><th scope="col">Median (km)</th><th scope="col">Min (km)</th><th scope="col">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 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, statusGreen())}</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 scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (km)</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></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)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}" 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>` : '—';
|
||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||
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><td>${mapBtn}</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 scope="col">#</th><th scope="col">Total Distance (km)</th><th scope="col">Hops</th><th scope="col">Route</th><th scope="col">Packet</th><th scope="col"></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>` : '—';
|
||
// Collect all unique pubkeys in path order
|
||
const pathPks = [];
|
||
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
|
||
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
|
||
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
|
||
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><td>${mapBtn}</td></tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
|
||
// Wire up map buttons
|
||
el.querySelectorAll('.dist-map-hop').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] }));
|
||
window.location.hash = '#/map?route=1';
|
||
});
|
||
});
|
||
el.querySelectorAll('.dist-map-path').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
try {
|
||
const hops = JSON.parse(btn.dataset.hops);
|
||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops }));
|
||
window.location.hash = '#/map?route=1';
|
||
} catch {}
|
||
});
|
||
});
|
||
} 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 = {}; _channelData = null; }
|
||
|
||
// Expose for testing
|
||
if (typeof window !== 'undefined') {
|
||
window._analyticsSortChannels = sortChannels;
|
||
window._analyticsLoadChannelSort = loadChannelSort;
|
||
window._analyticsSaveChannelSort = saveChannelSort;
|
||
window._analyticsChannelTbodyHtml = channelTbodyHtml;
|
||
window._analyticsChannelTheadHtml = channelTheadHtml;
|
||
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
|
||
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
|
||
window._analyticsBuildCollisionHops = buildCollisionHops;
|
||
}
|
||
|
||
registerPage('analytics', { init, destroy });
|
||
})();
|