/* === MeshCore Analyzer β 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,'"') : ''; }
// --- 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 `Sparkline showing trend of ${data.length} data points `;
}
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 = `Bar chart showing data distribution `;
// 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 += ` `;
svg += `${val} `;
}
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 += ` `;
if (labels[i]) svg += `${labels[i]} `;
});
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 = `
`;
// 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 =
`Failed to load: ${e.message}
`;
}
}
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`);
});
});
// 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 = `
${(rf.totalTransmissions || rf.totalAllPackets || rf.totalPackets).toLocaleString()}
Total Transmissions
${sparkSvg(rf.packetsPerHour.map(h=>h.count), 'var(--accent)')}
${rf.totalPackets.toLocaleString()}
Observations with Signal
${topo.uniqueNodes}
Unique Nodes
${sf(rf.snr.avg, 1)} dB
Avg SNR
${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}
${sf(rf.rssi.avg, 0)} dBm
Avg RSSI
${rf.rssi.min} to ${rf.rssi.max}
${sf(topo.avgHops, 1)}
Avg Hops
max ${topo.maxHops}
${ch.activeChannels}
Active Channels
${ch.decryptable} decryptable
${rf.avgPacketSize} B
Avg Packet Size
${rf.minPacketSize}β${rf.maxPacketSize} B
${((rf.timeSpanHours || 1)).toFixed(1)}h
Data Span
π Packets / Hour
${barChart(rf.packetsPerHour.map(h=>h.count), rf.packetsPerHour.map(h=>h.hour.slice(11)+'h'), 'var(--accent)')}
π¦ Payload Type Mix
${renderPayloadPie(rf.payloadTypes)}
π Hop Count Distribution
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
`;
}
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 = '';
types.forEach((t, i) => {
const pct = (t.count / total * 100).toFixed(1);
const w = Math.max(t.count / total * 100, 1);
html += `
${t.name}
${t.count} (${pct}%)
`;
});
return html + '
';
}
// ===================== RF / SIGNAL =====================
function renderRF(el, rf) {
const snrHist = histogram(rf.snrValues, 20, statusGreen());
const rssiHist = histogram(rf.rssiValues, 20, accentColor());
el.innerHTML = `
πΆ SNR Distribution
Signal-to-Noise Ratio (higher = cleaner signal)
${snrHist.svg}
Min: ${sf(rf.snr.min, 1)} dB
Mean: ${sf(rf.snr.avg, 1)} dB
Median: ${sf(rf.snr.median, 1)} dB
Max: ${sf(rf.snr.max, 1)} dB
Ο: ${sf(rf.snr.stddev, 1)} dB
π‘ RSSI Distribution
Received Signal Strength (closer to 0 = stronger)
${rssiHist.svg}
Min: ${rf.rssi.min} dBm
Mean: ${sf(rf.rssi.avg, 0)} dBm
Median: ${rf.rssi.median} dBm
Max: ${rf.rssi.max} dBm
Ο: ${sf(rf.rssi.stddev, 1)} dBm
π― SNR vs RSSI Scatter
Each dot = one packet. Cluster position reveals link quality.
${renderScatter(rf.scatterData)}
π SNR by Payload Type
${renderSNRByType(rf.snrByType)}
π Signal Quality Over Time
${renderSignalTimeline(rf.signalOverTime)}
π Packet Size Distribution
Raw packet length in bytes
${histogram(rf.packetSizes, 25, '#8b5cf6').svg}
Min: ${rf.minPacketSize} B
Avg: ${rf.avgPacketSize} B
Max: ${rf.maxPacketSize} B
`;
}
function renderScatter(data) {
const w = 600, h = 300, pad = 40;
const snrMin = -12, snrMax = 15, rssiMin = -130, rssiMax = -5;
let svg = `SNR vs RSSI scatter plot showing signal quality distribution `;
// Axes
svg += ` `;
svg += ` `;
svg += `SNR (dB) `;
svg += `RSSI (dBm) `;
// Grid labels
for (let snr = -10; snr <= 14; snr += 4) {
const x = pad + (snr - snrMin) / (snrMax - snrMin) * (w - pad * 2);
svg += `${snr} `;
}
for (let rssi = -120; rssi <= -20; rssi += 20) {
const y = h - pad - (rssi - rssiMin) / (rssiMax - rssiMin) * (h - pad * 2);
svg += `${rssi} `;
}
// 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 += ``;
svg += ` `;
svg += ` `;
svg += ` `;
svg += ` `;
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 += ` `;
svg += ` `;
svg += ` `;
svg += `${z.label} `;
});
// 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 += ` `;
});
svg += ' ';
return svg;
}
function renderSNRByType(snrByType) {
if (!snrByType.length) return 'No data
';
let html = 'Type Packets Avg SNR Min Max Distribution ';
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 += `
${t.name}
${t.count}
${sf(t.avg, 1)} dB
${sf(t.min, 1)}
${sf(t.max, 1)}
`;
});
return html + '
';
}
function renderSignalTimeline(data) {
if (!data.length) return 'No data
';
const w = 400, h = 160, pad = 35;
const maxPkts = Math.max(...data.map(d => d.count), 1);
let svg = `Signal quality over time showing SNR trend and packet volume `;
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 += ` `;
// 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 += ` `;
// 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 += `${data[i].hour.slice(11)}h `;
}
svg += ' ';
svg += ` Avg SNR Volume
`;
return svg;
}
// ===================== TOPOLOGY =====================
function renderTopology(el, topo) {
el.innerHTML = `
π Hop Count Distribution
Number of repeater hops per packet
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
Avg: ${sf(topo.avgHops, 1)} hops
Median: ${topo.medianHops}
Max: ${topo.maxHops}
1-hop direct: ${topo.hopDistribution[0]?.count || 0}
πΈοΈ Top Repeaters
Nodes appearing most in packet paths
${renderRepeaterTable(topo.topRepeaters)}
π€ Repeater Pair Heatmap
Which repeaters frequently appear together in paths
${renderPairTable(topo.topPairs)}
π Hops vs SNR
Does more hops = worse signal?
${renderHopsSNR(topo.hopsVsSnr)}
π Best Path to Each Node
Shortest hop distance seen across all observers
${renderBestPath(topo.bestPathList)}
π Per-Observer Reachability
Nodes at each hop distance, from each observer's perspective
${topo.observers.length > 1 ? `
${topo.observers.map((o, i) => `${esc(o.name)} `).join('')}
All Observers
` : ''}
${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}
${topo.multiObsNodes.length ? `
π Cross-Observer Comparison
Nodes seen by multiple observers β hop distance varies by vantage point
${renderCrossObserver(topo.multiObsNodes)}
` : ''}
`;
// 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 'No data
';
const max = repeaters[0].count;
let html = '';
repeaters.slice(0, 15).forEach(r => {
const pct = (r.count / max * 100).toFixed(0);
html += `
${r.name ? '' + esc(r.name) + ' ' : '' + r.hop + ' '}
${r.count.toLocaleString()}
`;
});
return html + '
';
}
function renderPairTable(pairs) {
if (!pairs.length) return 'Not enough multi-hop data
';
let html = 'Node A Node B Co-appearances ';
pairs.slice(0, 12).forEach(p => {
html += `
${p.nameA ? `${esc(p.nameA)} ` : `${p.hopA} `}
${p.nameB ? `${esc(p.nameB)} ` : `${p.hopB} `}
${p.count}
`;
});
return html + '
';
}
function renderHopsSNR(data) {
if (!data.length) return 'No data
';
const w = 380, h = 160, pad = 40;
const maxHop = Math.max(...data.map(d => d.hops));
let svg = `Hops vs SNR bubble chart showing signal degradation over distance `;
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 += ` `;
svg += `${d.hops}h `;
});
svg += `Hops `;
svg += `Avg SNR `;
svg += ' ';
return svg;
}
function renderPerObserverReach(perObserverReach, obsId) {
const data = perObserverReach[obsId];
if (!data || !data.rings.length) return 'No path data for this observer
';
let html = ``;
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 ? `
${esc(n.name)} ` : `
${n.hop} `;
const detail = n.distRange ? `
(${n.distRange}) ` : '';
return label + detail;
}).join(', ');
const extra = ring.nodes.length > 8 ? `
+${ring.nodes.length - 8} more ` : '';
html += `
${ring.hops} hop${ring.hops > 1 ? 's' : ''}
${nodeLinks}${extra}
${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}
`;
});
return html + '
';
}
function renderAllObserversReach(perObserverReach) {
let html = '';
for (const [obsId, data] of Object.entries(perObserverReach)) {
html += `π‘ ${esc(data.observer_name)} `;
html += renderPerObserverReach(perObserverReach, obsId);
}
return html || 'No data
';
}
function renderCrossObserver(nodes) {
if (!nodes.length) return 'No nodes seen by multiple observers
';
let html = `
Node Observers Hop Distances `;
nodes.forEach(n => {
const name = n.name
? `${esc(n.name)} `
: `${n.hop} `;
const obsInfo = n.observers.map(o =>
`${esc(o.observer_name)}: ${o.minDist} hop${o.minDist > 1 ? 's' : ''} (${o.count} pkts) `
).join(' ');
html += `${name} ${n.observers.length} ${obsInfo} `;
});
return html + '
';
}
function renderBestPath(nodes) {
if (!nodes.length) return 'No data
';
// 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 = '';
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
? `
${esc(n.name)} `
: `
${n.hop} `;
return label + `
via ${esc(n.observer_name)} `;
}).join(', ');
const extra = nodes.length > 10 ? `
+${nodes.length - 10} more ` : '';
html += `
${dist} hop${+dist > 1 ? 's' : ''}
${nodeLinks}${extra}
${nodes.length} node${nodes.length > 1 ? 's' : ''}
`;
});
return html + '
';
}
// ===================== CHANNELS =====================
function renderChannels(el, ch) {
el.innerHTML = `
π» Channel Activity
${ch.activeChannels} active channels, ${ch.decryptable} decryptable
Channel Hash Messages Unique Senders Last Activity Decrypted
${ch.channels.map(c => `
${esc(c.name || 'Unknown')}
${typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash}
${c.messages}
${c.senders}
${timeAgo(c.lastActivity)}
${c.encrypted ? 'π' : 'β
'}
`).join('')}
π¬ Messages / Hour by Channel
${renderChannelTimeline(ch.channelTimeline)}
π£οΈ Top Senders
${renderTopSenders(ch.topSenders)}
π Message Length Distribution
${ch.msgLengths.length ? histogram(ch.msgLengths, 20, '#8b5cf6').svg : '
No decrypted messages
'}
`;
}
function renderChannelTimeline(data) {
if (!data.length) return 'No data
';
const hours = [...new Set(data.map(d => d.hour))].sort();
const channels = [...new Set(data.map(d => d.channel))];
const colors = ['#ef4444','#22c55e','#3b82f6','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#64748b'];
const w = 600, h = 180, pad = 35;
let svg = `Channel message activity over time `;
channels.forEach((ch, ci) => {
const pts = hours.map((hr, i) => {
const entry = data.find(d => d.hour === hr && d.channel === ch);
const count = entry ? entry.count : 0;
const max = Math.max(...data.map(d => d.count), 1);
const x = pad + i * ((w - pad * 2) / Math.max(hours.length - 1, 1));
const y = h - pad - (count / max) * (h - pad * 2);
return `${x},${y}`;
}).join(' ');
svg += ` `;
});
const step = Math.max(1, Math.floor(hours.length / 6));
for (let i = 0; i < hours.length; i += step) {
const x = pad + i * ((w - pad * 2) / Math.max(hours.length - 1, 1));
svg += `${hours[i].slice(11)}h `;
}
svg += ' ';
svg += `${channels.map((ch, i) => ` ${esc(ch)} `).join('')}
`;
return svg;
}
function renderTopSenders(senders) {
if (!senders.length) return 'No decrypted messages
';
const max = senders[0].count;
let html = '';
senders.slice(0, 10).forEach(s => {
html += `
${esc(s.name)}
${s.count} msgs
`;
});
return html + '
';
}
// ===================== 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 = `
Hash Size Distribution
${total.toLocaleString()} packets with path hops
${[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 `
${size}-byte (${size * 8}-bit, ${Math.pow(256, size).toLocaleString()} IDs)
${count.toLocaleString()} (${pct(count)}%)
`;
}).join('')}
π Hash Size Over Time
${renderHashTimeline(data.hourly)}
Multi-Byte Hash Adopters
Nodes advertising with 2+ byte hash paths
${data.multiByteNodes.length ? `
Node Hash Size Adverts Last Seen
${data.multiByteNodes.map(n => `
${esc(n.name)}
${n.hashSize}-byte
${n.packets}
${timeAgo(n.lastSeen)}
`).join('')}
` : '
No multi-byte adopters found
'}
Top Path Hops
Hop Node Bytes Appearances
${data.topHops.map(h => {
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
return `
${h.hex}
${h.name ? `${esc(h.name)} ` : 'unknown '}
${h.size}-byte
${h.count.toLocaleString()}
`;
}).join('')}
`;
}
async function renderCollisionTab(el, data) {
el.innerHTML = `
β οΈ Inconsistent Sizes
|
π’ Hash Matrix
|
π₯ Collision Risk
β οΈ Inconsistent Hash Sizes β top
Nodes sending adverts with varying hash sizes. Caused by a bug where automatic adverts ignored the configured multibyte path setting. Fixed in repeater v1.14.1 .
π’ 1-Byte Hash Usage Matrix β top
Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.
`;
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 = 'β
No inconsistencies detected β all nodes are reporting consistent hash sizes.
';
} else {
ihEl.innerHTML = `
Node Role Current Hash Sizes Seen
${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 = (n.hash_sizes_seen || []).map(s => {
const c = s >= 3 ? '#16a34a' : s === 2 ? '#86efac' : '#f97316';
const fg = s === 2 ? '#064e3b' : '#fff';
return '' + s + 'B ';
}).join(' ');
const stripe = i % 2 === 1 ? 'background:var(--row-stripe)' : '';
return `
${esc(n.name || n.public_key.slice(0, 12))}
${n.role}
${prefix} (${n.hash_size || '?'}B)
${sizeBadges}
`;
}).join('')}
${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.
`;
}
}
renderHashMatrix(data.topHops, allNodes);
renderCollisions(data.topHops, allNodes);
}
function renderHashTimeline(hourly) {
if (!hourly.length) return 'Not enough data
';
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 = `Hash size distribution over time showing 1-byte, 2-byte, and 3-byte hash trends `;
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 += ` `;
}
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 += `${hourly[i].hour.slice(11)}h `;
}
svg += ' ';
svg += ` 1-byte 2-byte 3-byte
`;
return svg;
}
async function renderHashMatrix(topHops, allNodes) {
const el = document.getElementById('hashMatrix');
// Build prefix β node count map
const prefixNodes = {};
for (let i = 0; i < 256; i++) {
const hex = i.toString(16).padStart(2, '0').toUpperCase();
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
}
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
let html = `
0 β Available
1 β One node
2 β Two nodes (collision)
3+ β Three+ nodes (collision)
`;
el.innerHTML = html;
// Click handler for cells
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 = `0x${hex} No known nodes `;
return;
}
detail.innerHTML = `0x${hex} β ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
`${matches.map(m => {
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? `
(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)}) `
: '
(no coords) ';
const role = m.role ? `
${esc(m.role)} ` : '';
return `
`;
}).join('')}
`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
}
async function renderCollisions(topHops, allNodes) {
const el = document.getElementById('collisionList');
const oneByteHops = topHops.filter(h => h.size === 1);
if (!oneByteHops.length) { el.innerHTML = 'No 1-byte hops
'; return; }
try {
const nodes = allNodes;
const collisions = [];
for (const hop of oneByteHops) {
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) { el.innerHTML = 'No collisions detected
'; 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);
el.innerHTML = `
Hop Appearances Max Distance Assessment Colliding Nodes
${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = 'ποΈ Local ';
tooltip = 'Nodes close enough for direct RF β probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = 'β‘ Regional ';
tooltip = 'At edge of 915MHz range β could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = 'π Distant ';
tooltip = 'Beyond typical LoRa range β likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = 'β Unknown ';
tooltip = 'Not enough coordinate data to classify';
}
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : 'β ';
return `
${c.hop}
${c.count.toLocaleString()}
${distStr}
${badge}
${c.matches.map(m => {
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)}) `
: ' (no coords) ';
return `${esc(m.name || m.public_key.slice(0,12))} ${loc}`;
}).join(' ')}
`;
}).join('')}
ποΈ Local <50km: true prefix collision, same mesh area
β‘ Regional 50β200km: edge of LoRa range, possible atmospheric propagation
π Distant >200km: beyond 915MHz range β internet bridge, MQTT gateway, or separate networks
`;
} catch { el.innerHTML = 'Failed to load
'; }
}
async function renderSubpaths(el) {
el.innerHTML = 'Analyzing route patternsβ¦
';
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 `${title} No data
`;
const maxCount = data.subpaths[0]?.count || 1;
return `${title}
From ${data.totalPaths.toLocaleString()} paths with 2+ hops
# Route Occurrences % of paths Frequency
${data.subpaths.map((s, i) => {
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
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 `
${i + 1}
${routeDisplay}${hasSelfLoop ? ' π ' : ''}${esc(prefixDisplay)}
${s.count.toLocaleString()}
${s.pct}%
`;
}).join('')}
`;
}
el.innerHTML = `
π€οΈ Route Pattern Analysis
Click a route to see details. Most common subpaths β reveals backbone routes, bottlenecks, and preferred relay chains.
Hide likely prefix collisions (self-loops)
${renderTable(d2, 'Pairs (2-hop links)')}
${renderTable(d3, 'Triples (3-hop chains)')}
${renderTable(d4, 'Quads (4-hop chains)')}
${renderTable(d5, 'Long chains (5+ hops)')}
Select a route to view details
`;
// Click handler for rows
el.addEventListener('click', e => {
const tr = e.target.closest('tr[data-hops]');
if (!tr) return;
el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected'));
tr.classList.add('subpath-selected');
loadSubpathDetail(tr.dataset.hops);
});
// 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 = `Error loading subpath data: ${e.message}
`;
}
}
async function loadSubpathDetail(hopsStr) {
const panel = document.getElementById('subpathDetail');
panel.classList.remove('collapsed');
panel.innerHTML = 'Loadingβ¦
';
try {
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
renderSubpathDetail(panel, data);
} catch (e) {
panel.innerHTML = `Error: ${e.message}
`;
}
}
function renderSubpathDetail(panel, data) {
const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0));
const hasMap = nodesWithLoc.length >= 2;
const maxHour = Math.max(...data.hourDistribution, 1);
panel.innerHTML = `
${data.nodes.map(n => esc(n.name)).join(' β ')}
${data.hops.join(' β ')}
${data.totalMatches.toLocaleString()} occurrences
${nodesWithLoc.length >= 2 ? `
π Hop Distances
${(() => {
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(`
${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'} ${esc(a.name)} β ${esc(b.name)}
`);
} else {
dists.push(`
? ${esc(a.name)} β ${esc(b.name)} (no coords)
`);
}
}
if (dists.length > 1) dists.push(`
Total: ${total < 1 ? (total*1000).toFixed(0)+'m' : total.toFixed(1)+'km'}
`);
return dists.join('');
})()}
` : ''}
${hasMap ? '
' : ''}
π‘ Observer Receive Signal
Last hop β observer only, not between nodes in the route
${data.signal.avgSnr != null
? `
Avg SNR: ${data.signal.avgSnr} dB Β· Avg RSSI: ${data.signal.avgRssi} dBm Β· ${data.signal.samples} samples
`
: '
No signal data
'}
π Activity by Hour (UTC)
${data.hourDistribution.map((c, h) => `
`).join('')}
0 6 12 18 23
β±οΈ Timeline
First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : 'β'}
Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : 'β'}
${data.observers.length ? `
ποΈ Observers
${data.observers.map(o => `
${esc(o.name)}: ${o.count}
`).join('')}
` : ''}
${data.parentPaths.length ? `
π Full Paths Containing This Route
${data.parentPaths.map(p => `
${esc(p.path)} Γ${p.count}
`).join('')}
` : ''}
`;
// Render minimap
if (hasMap && typeof L !== 'undefined') {
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
L.tileLayer(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 = 'Loading node analyticsβ¦
';
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 `${esc(n.name || n.public_key.slice(0, 12))} `;
}
function claimedBadge(n) {
return myKeys.has(n.public_key) ? ' β
MINE ' : '';
}
// ROLE_COLORS from shared roles.js
el.innerHTML = `
π Network Status
${degraded}
π‘ Degraded
${totalNodes}
Total Nodes
π Role Breakdown
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
const c = ROLE_COLORS[role] || '#6b7280';
return `${role}: ${count} `;
}).join('')}
${myKeys.size ? `
β My Claimed Nodes
Node Role Packets Avg SNR Observers Last Heard
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
const s = n.health.stats;
return `
${nodeLink(n)}
${n.role}
${s.totalTransmissions || s.totalPackets || 0}
${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : 'β'}
${n.health.observers?.length || 0}
${s.lastHeard ? timeAgo(s.lastHeard) : 'β'}
`;
}).join('') || 'No claimed nodes have health data '}
` : ''}
π Most Active Nodes
# Node Role Total Packets Packets Today Analytics
${byPackets.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}
${n.health.stats.packetsToday || 0}
π
`).join('')}
πΆ Best Signal Quality
# Node Role Avg SNR Observers Analytics
${bySnr.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.stats.avgSnr.toFixed(1)} dB
${n.health.observers?.length || 0}
π
`).join('')}
π Most Observed Nodes
# Node Role Observers Avg SNR Analytics
${byObservers.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.observers?.length || 0}
${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : 'β'}
π
`).join('')}
β° Recently Active
Node Role Last Heard Packets Today Analytics
${byRecent.slice(0, 15).map(n => `
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${timeAgo(n.health.stats.lastHeard)}
${n.health.stats.packetsToday || 0}
π
`).join('')}
`;
} catch (e) {
el.innerHTML = `Failed to load node analytics: ${esc(e.message)}
`;
}
}
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 = `
${s.totalHops.toLocaleString()}
Total Hops Analyzed
${s.totalPaths.toLocaleString()}
Paths Analyzed
${s.avgDist} km
Avg Hop Distance
${s.maxDist} km
Max Hop Distance
`;
// Category stats
const cats = data.catStats;
html += `Distance by Link Type Type Count Avg (km) Median (km) Min (km) Max (km) `;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `${esc(cat)} ${st.count.toLocaleString()} ${st.avg} ${st.median} ${st.min} ${st.max} `;
}
html += `
`;
// 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 += `
Hop Distance Distribution ${barChart(buckets, labels, statusGreen())}`;
}
// Distance over time
if (data.distOverTime && data.distOverTime.length > 1) {
html += `
Average Distance Over Time ${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}`;
}
// Top hops leaderboard
html += `π Top 20 Longest Hops # From To Distance (km) Type SNR Packet `;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `${esc(h.fromName)} ` : esc(h.fromName || '?');
const toLink = h.toPk ? `${esc(h.toName)} ` : esc(h.toName || '?');
const snr = h.snr != null ? h.snr + ' dB' : 'β ';
const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}β¦ ` : 'β';
const mapBtn = h.fromPk && h.toPk ? `πΊοΈ ` : '';
html += `${i+1} ${fromLink} ${toLink} ${h.dist} ${esc(h.type)} ${snr} ${pktLink} ${mapBtn} `;
});
html += `
`;
// Top paths
if (data.topPaths.length) {
html += `π€οΈ Top 10 Longest Multi-Hop Paths # Total Distance (km) Hops Route Packet `;
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 ? `${esc(p.hash.slice(0, 12))}β¦ ` : 'β';
// 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 ? `πΊοΈ ` : '';
html += `${i+1} ${p.totalDist} ${p.hopCount} ${route} ${pktLink} ${mapBtn} `;
});
html += `
`;
}
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 = `Failed to load distance analytics: ${esc(e.message)}
`;
}
}
function destroy() { _analyticsData = {}; }
registerPage('analytics', { init, destroy });
})();