/* === 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 = `

πŸ“Š Mesh Analytics

Deep dive into your mesh network data

Loading analytics…
`; // 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 = ``; } } 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 = ''; 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 += ``; }); return html + '
TypePacketsAvg SNRMinMaxDistribution
${t.name} ${t.count} ${sf(t.avg, 1)} dB ${sf(t.min, 1)} ${sf(t.max, 1)}
'; } 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 SNRVolume
`; 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) => ``).join('')}
` : ''}
${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 = ''; pairs.slice(0, 12).forEach(p => { html += ``; }); return html + '
Node ANode BCo-appearances
${p.nameA ? `${esc(p.nameA)}` : `${p.hopA}`} ${p.nameB ? `${esc(p.nameB)}` : `${p.hopB}`} ${p.count}
'; } 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 = ``; 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 += ``; }); return html + '
NodeObserversHop Distances
${name}${n.observers.length}${obsInfo}
'; } 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

${ch.channels.map(c => ``).join('')}
ChannelHashMessagesUnique SendersLast ActivityDecrypted
${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 ? 'πŸ”’' : 'βœ…'}

πŸ’¬ 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 ? ` ${data.multiByteNodes.map(n => ``).join('')}
NodeHash SizeAdvertsLast Seen
${esc(n.name)} ${n.hashSize}-byte ${n.packets} ${timeAgo(n.lastSeen)}
` : '
No multi-byte adopters found
'}

Top Path Hops

${data.topHops.map(h => { const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`; return ``; }).join('')}
HopNodeBytesAppearances
${h.hex} ${h.name ? `${esc(h.name)}` : 'unknown'} ${h.size}-byte ${h.count.toLocaleString()}
`; } async function renderCollisionTab(el, data) { el.innerHTML = `

⚠️ 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.

Loading…

πŸ”’ 1-Byte Hash Usage Matrix

↑ top

Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.

πŸ’₯ 1-Byte Collision Risk

↑ top
Loading…
`; 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 = `${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 ``; }).join('')}
NodeRoleCurrent HashSizes Seen
${esc(n.name || n.public_key.slice(0, 12))} ${n.role} ${prefix} (${n.hash_size || '?'}B) ${sizeBadges}

${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-byte2-byte3-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 = `
`; html += ``; for (const n of nibbles) { html += ``; } html += ''; for (let hi = 0; hi < 16; hi++) { html += ``; for (let lo = 0; lo < 16; lo++) { const hex = nibbles[hi] + nibbles[lo]; const nodes = prefixNodes[hex] || []; const count = nodes.length; let bg, color; if (count === 0) { bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle } else if (count === 1) { bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision } else { // 2+ nodes: orange→red const t = Math.min((count - 2) / 4, 1); const r = Math.round(220 + 35 * t); const g = Math.round(120 * (1 - t)); bg = `rgb(${r},${g},30)`; color = '#fff'; } const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`; const cellText = count === 0 ? `${hex}` : count >= 2 ? `${count >= 3 ? '3+' : count}` : String(count); html += ``; } html += ''; } html += '
${n}
${nibbles[hi]}${cellText}
'; 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 `
${role}${esc(m.name || m.public_key.slice(0,12))} ${coords}
`; }).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 = `${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 ``; }).join('')}
HopAppearancesMax DistanceAssessmentColliding Nodes
${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('
')}
🏘️ 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

${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 ``; }).join('')}
#RouteOccurrences% of pathsFrequency
${i + 1} ${routeDisplay}${hasSelfLoop ? ' πŸ”„' : ''}
${esc(prefixDisplay)}
${s.count.toLocaleString()} ${s.pct}%
`; } el.innerHTML = `

πŸ›€οΈ Route Pattern Analysis

Click a route to see details. Most common subpaths β€” reveals backbone routes, bottlenecks, and preferred relay chains.

${renderTable(d2, 'Pairs (2-hop links)')}
${renderTable(d3, 'Triples (3-hop chains)')}
${renderTable(d4, 'Quads (4-hop chains)')}
${renderTable(d5, 'Long chains (5+ hops)')}
`; // 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('')}
06121823
⏱️ 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

${active}
🟒 Active
${degraded}
🟑 Degraded
${silent}
πŸ”΄ Silent
${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

${enriched.filter(n => myKeys.has(n.public_key)).map(n => { const s = n.health.stats; return ``; }).join('') || ''}
NodeRolePacketsAvg SNRObserversLast Heard
${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) : 'β€”'}
No claimed nodes have health data
` : ''}

πŸ† Most Active Nodes

${byPackets.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleTotal PacketsPackets TodayAnalytics
${i + 1} ${nodeLink(n)}${claimedBadge(n)} ${n.role} ${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0} ${n.health.stats.packetsToday || 0} πŸ“Š

πŸ“Ά Best Signal Quality

${bySnr.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleAvg SNRObserversAnalytics
${i + 1} ${nodeLink(n)}${claimedBadge(n)} ${n.role} ${n.health.stats.avgSnr.toFixed(1)} dB ${n.health.observers?.length || 0} πŸ“Š

πŸ‘€ Most Observed Nodes

${byObservers.slice(0, 15).map((n, i) => ``).join('')}
#NodeRoleObserversAvg SNRAnalytics
${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' : 'β€”'} πŸ“Š

⏰ Recently Active

${byRecent.slice(0, 15).map(n => ``).join('')}
NodeRoleLast HeardPackets TodayAnalytics
${nodeLink(n)}${claimedBadge(n)} ${n.role} ${timeAgo(n.health.stats.lastHeard)} ${n.health.stats.packetsToday || 0} πŸ“Š
`; } 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

`; for (const [cat, st] of Object.entries(cats)) { if (!st.count) continue; html += ``; } html += `
TypeCountAvg (km)Median (km)Min (km)Max (km)
${esc(cat)}${st.count.toLocaleString()}${st.avg}${st.median}${st.min}${st.max}
`; // 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

`; 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 += ``; }); html += `
#FromToDistance (km)TypeSNRPacket
${i+1}${fromLink}${toLink}${h.dist}${esc(h.type)}${snr}${pktLink}${mapBtn}
`; // Top paths if (data.topPaths.length) { html += `

πŸ›€οΈ Top 10 Longest Multi-Hop Paths

`; 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 += ``; }); html += `
#Total Distance (km)HopsRoutePacket
${i+1}${p.totalDist}${p.hopCount}${route}${pktLink}${mapBtn}
`; } 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 }); })();