/* === 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,'"') : ''; } // --- 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, collisionData] = 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 }), api('/analytics/hash-collisions' + sep, { ttl: CLIENT_TTL.analyticsRF }), ]); _analyticsData = { hashData, rfData, topoData, chanData, collisionData }; 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, d.collisionData); break; case 'subpaths': await renderSubpaths(el); break; case 'nodes': await renderNodesTab(el); break; case 'distance': await renderDistanceTab(el); break; case 'neighbor-graph': await renderNeighborGraphTab(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 = `
${(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'])}
`; // Affinity stats widget β€” fetch and append if debugAffinity enabled var showDebug = (window.CLIENT_CONFIG && window.CLIENT_CONFIG.debugAffinity) || localStorage.getItem('meshcore-affinity-debug') === 'true'; if (showDebug) { var apiKey = localStorage.getItem('meshcore-api-key') || ''; fetch('/api/debug/affinity', { headers: { 'X-API-Key': apiKey } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data || !data.stats) return; var s = data.stats; var total = s.resolvedCount + s.ambiguousCount + s.unresolvedCount; var resolvedPct = total > 0 ? (s.resolvedCount / total * 100).toFixed(1) : '0.0'; var ambiguousPct = total > 0 ? (s.ambiguousCount / total * 100).toFixed(1) : '0.0'; var widget = document.createElement('div'); widget.className = 'analytics-row'; widget.innerHTML = '
' + '

πŸ” Neighbor Affinity Graph

' + '
' + '
' + s.totalEdges + '
Total Edges
' + '
' + s.totalNodes + '
Total Nodes
' + '
' + s.resolvedCount + ' (' + resolvedPct + '%)
Resolved Prefixes
' + '
' + s.ambiguousCount + ' (' + ambiguousPct + '%)
Ambiguous Prefixes
' + '
' + (s.avgConfidence || 0).toFixed(3) + '
Avg Confidence
' + '
' + (s.coldStartCoverage || 0).toFixed(1) + '%
Cold-Start Coverage
' + '
' + (s.cacheAge || 'N/A') + '
Cache Age
' + '
' + (s.lastRebuild ? s.lastRebuild.substring(0, 19) : 'N/A') + '
Last Rebuild
' + '
'; el.appendChild(widget); }) .catch(function () {}); } } 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 ===================== 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 '' + '' + 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 ? 'πŸ”’' : 'βœ…') + '' + ''; } 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 'β‡…'; return '' + (dir === 'asc' ? '↑' : '↓') + ''; } 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 += '' + c.label + channelSortArrow(c.key, activeCol, dir) + ''; } return '' + ths + ''; } 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 : '
No decrypted messages
'; el.innerHTML = '
' + '

πŸ“» Channel Activity

' + '

' + ch.activeChannels + ' active channels, ' + ch.decryptable + ' decryptable

' + '' + channelTheadHtml(_channelSortState.col, _channelSortState.dir) + '' + channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) + '' + '
' + '
' + '
' + '
' + '

πŸ’¬ Messages / Hour by Channel

' + timelineHtml + '
' + '
' + '

πŸ—£οΈ Top Senders

' + topSendersHtml + '
' + '
' + '
' + '

πŸ“Š Message Length Distribution

' + histoHtml + '
'; // 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 '
No data
'; 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 = 'Channel message activity over time'; 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 += ''; } 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 += '' + hours[li].slice(11) + 'h'; } svg += ''; var legendParts = []; for (var lci = 0; lci < channelList.length; lci++) { legendParts.push('' + esc(channelList[lci]) + ''); } svg += '
' + legendParts.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('')}
${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 `

By Repeaters

${totalRepeaters.toLocaleString()} unique repeaters

${[1, 2, 3].map(size => { const count = dr[size] || 0; const width = Math.max((count / maxRepeaters) * 100, count ? 2 : 0); return `
${size}-byte
${count.toLocaleString()} (${rpct(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, collisionData) { 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…

πŸ”’ Hash Usage Matrix

↑ top

Click a cell to see which nodes share that prefix.

πŸ’₯ Collision Risk

↑ top
Loading…
`; // Use pre-computed collision data from server (no more /nodes?limit=2000 fetch) const cData = collisionData || { inconsistent_nodes: [], by_size: {} }; const inconsistent = cData.inconsistent_nodes || []; 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 = (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 '' + 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.

`; } } // Repeaters and routing nodes no longer needed β€” collision data is server-computed 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.'; } renderHashMatrixFromServer(cData.by_size[String(bytes)], bytes); // 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) renderCollisionsFromServer(cData.by_size[String(bytes)], 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 '
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; } // Shared hover tooltip for hash matrix cells. // Called once per container β€” reads content from data-tip on each . // 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); } // --- Shared helpers for hash matrix rendering --- function hashStatCardsHtml(totalNodes, usingCount, sizeLabel, spaceSize, usedCount, collisionCount) { const pct = spaceSize > 0 && usedCount > 0 ? ((usedCount / spaceSize) * 100) : 0; const pctStr = spaceSize > 65536 ? pct.toFixed(6) : spaceSize > 256 ? pct.toFixed(3) : pct.toFixed(1); const spaceLabel = spaceSize >= 1e6 ? (spaceSize / 1e6).toFixed(1) + 'M' : spaceSize.toLocaleString(); return `
Nodes tracked
${totalNodes.toLocaleString()}
Using ${sizeLabel} ID
${usingCount.toLocaleString()}
Prefix space used
${pctStr}%
${usedCount > 256 ? usedCount + ' of ' : 'of '}${spaceLabel} possible
Prefix collisions
${collisionCount}
`; } function hashMatrixGridHtml(nibbles, cellSize, headerSize, cellDataFn) { 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++) { html += cellDataFn(nibbles[hi] + nibbles[lo], cellSize); } html += ''; } html += '
${n}
${nibbles[hi]}
'; return html; } function hashMatrixLegendHtml(labels) { return `
${labels.map(l => ` ${l.text}`).join('\n')}
`; } function renderHashMatrixFromServer(sizeData, bytes) { const el = document.getElementById('hashMatrix'); if (!sizeData) { el.innerHTML = '
No data
'; return; } const stats = sizeData.stats || {}; const totalNodes = stats.total_nodes || 0; // 3-byte: show a summary panel instead of a matrix if (bytes === 3) { el.innerHTML = hashStatCardsHtml(totalNodes, stats.using_this_size || 0, '3-byte', 16777216, stats.unique_prefixes || 0, stats.collision_count || 0) + `

The 3-byte prefix space (16.7M values) is too large to visualize as a grid.

`; return; } const nibbles = '0123456789ABCDEF'.split(''); const cellSize = 36; const headerSize = 24; if (bytes === 1) { const oneByteCells = sizeData.one_byte_cells || {}; const oneByteCount = stats.using_this_size || 0; const oneUsed = Object.values(oneByteCells).filter(v => v.length > 0).length; const oneCollisions = Object.values(oneByteCells).filter(v => v.length > 1).length; let html = hashStatCardsHtml(totalNodes, oneByteCount, '1-byte', 256, oneUsed, oneCollisions); html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => { const nodes = oneByteCells[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 => `
${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' (unknown role)' : ''}
`; const tip1 = count === 0 ? `
0x${hex}
Available
` : count === 1 ? `
0x${hex}
One node β€” no collision
${nodeLabel(nodes[0])}
` : isPossible ? `
0x${hex}
${count} nodes β€” POSSIBLE CONFLICT
${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`
+${nodes.length-5} more
`:''}
` : `
0x${hex}
${count} nodes β€” COLLISION
${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`
+${nodes.length-5} more
`:''}
`; return `${hex}`; }); html += `
`; html += hashMatrixLegendHtml([ {cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'Available'}, {cls: 'hash-cell-taken', text: 'One node'}, {cls: 'hash-cell-possible', text: 'Possible conflict'}, {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'} ]); el.innerHTML = html; initMatrixTooltip(el); el.querySelectorAll('.hash-active').forEach(td => { td.addEventListener('click', () => { const hex = td.dataset.hex.toUpperCase(); const matches = oneByteCells[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'); }); }); } else if (bytes === 2) { const twoByteCells = sizeData.two_byte_cells || {}; const twoByteCount = stats.using_this_size || 0; const uniqueTwoBytePrefixes = stats.unique_prefixes || 0; const twoCollisions = Object.values(twoByteCells).filter(v => v.collision_count > 0).length; let html = hashStatCardsHtml(totalNodes, twoByteCount, '2-byte', 65536, uniqueTwoBytePrefixes, twoCollisions); html += hashMatrixGridHtml(nibbles, cellSize, headerSize, (hex, cs) => { const info = twoByteCells[hex] || { group_nodes: [], max_collision: 0, collision_count: 0, two_byte_map: {} }; const nodeCount = (info.group_nodes || []).length; const maxCol = info.max_collision || 0; const overlapping = Object.values(info.two_byte_map || {}).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 ? `
0x${hex}__
No nodes in this group
` : (info.collision_count || 0) === 0 ? `
0x${hex}__
${nodeCount} node${nodeCount>1?'s':''} β€” no 2-byte collisions
` : `
0x${hex}__
${hasConfirmed ? (info.collision_count||0) + ' collision' + ((info.collision_count||0)>1?'s':'') : 'Possible conflict'}
${Object.entries(info.two_byte_map||{}).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`
${p} β€” ${ns.map(nodeLabel2).join(', ')}
`).join('')}
`; return `${hex}`; }); html += `
`; html += hashMatrixLegendHtml([ {cls: 'hash-cell-empty', style: 'border:1px solid var(--border)', text: 'No nodes in group'}, {cls: 'hash-cell-taken', text: 'Nodes present, no collision'}, {cls: 'hash-cell-possible', text: 'Possible conflict'}, {cls: 'hash-cell-collision', style: 'background:rgb(220,80,30)', text: 'Collision'} ]); el.innerHTML = html; el.querySelectorAll('.hash-active').forEach(td => { td.addEventListener('click', () => { const hex = td.dataset.hex.toUpperCase(); const info = twoByteCells[hex]; const detail = document.getElementById('hashDetail'); if (!info || !(info.group_nodes || []).length) { detail.innerHTML = ''; return; } const groupNodes = info.group_nodes || []; let dhtml = `0x${hex}__ β€” ${groupNodes.length} node${groupNodes.length !== 1 ? 's' : ''} in group`; if ((info.collision_count || 0) === 0) { dhtml += `
βœ… No 2-byte collisions in this group
`; dhtml += `
${groupNodes.map(m => { const prefix = m.public_key.slice(0,4).toUpperCase(); return `
${prefix} ${esc(m.name || m.public_key.slice(0,12))}
`; }).join('')}
`; } else { dhtml += `
`; for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) { const isCollision = nodes.length > 1; dhtml += `
`; dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', '); dhtml += `
`; } dhtml += '
'; } detail.innerHTML = dhtml; el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); td.classList.add('hash-selected'); }); }); initMatrixTooltip(el); } } function renderCollisionsFromServer(sizeData, bytes) { const el = document.getElementById('collisionList'); if (!sizeData) { el.innerHTML = '
No data
'; return; } const collisions = sizeData.collisions || []; 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 = `
${cleanMsg}
`; return; } const showAppearances = bytes < 3; el.innerHTML = ` ${showAppearances ? '' : ''} ${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 nodes = c.nodes || []; const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : 'β€”'; return ` ${showAppearances ? `` : ''} `; }).join('')}
PrefixAppearancesMax Distance Assessment Colliding Nodes
${c.prefix}${(c.appearances || 0).toLocaleString()}${distStr} ${badge} ${nodes.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
`; } 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 km = window.HopResolver && window.HopResolver.haversineKm ? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon) : (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })(); 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 = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; } // Expose for testing if (typeof window !== 'undefined') { window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; window._analyticsSaveChannelSort = saveChannelSort; window._analyticsChannelTbodyHtml = channelTbodyHtml; window._analyticsChannelTheadHtml = channelTheadHtml; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── let _ngState = null; // neighbor graph state async function renderNeighborGraphTab(el) { el.innerHTML = `

πŸ•ΈοΈ Neighbor Graph

πŸ“‹ Text-based neighbor list (accessible alternative)
`; // Role checkboxes const roles = ['repeater','companion','room','sensor']; const rcEl = document.getElementById('ngRoleChecks'); roles.forEach(r => { const color = (window.ROLE_COLORS || {})[r] || '#888'; rcEl.innerHTML += ``; }); // Load data const rqs = RegionFilter.regionQueryString(); const sep = rqs ? '?' + rqs.slice(1) : ''; let graphData; try { graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }); } catch (e) { el.innerHTML = `

Failed to load neighbor graph: ${esc(e.message)}

`; return; } _ngState = createGraphState(graphData); renderNGStats(_ngState); startGraphRenderer(); // Filter listeners document.getElementById('ngMinScore').addEventListener('input', function() { document.getElementById('ngMinScoreVal').textContent = (this.value / 100).toFixed(2); applyNGFilters(); }); document.getElementById('ngConfidence').addEventListener('change', applyNGFilters); rcEl.addEventListener('change', applyNGFilters); } function createGraphState(data) { const nodes = (data.nodes || []).map((n, i) => ({ ...n, x: 450 + (Math.random() - 0.5) * 400, y: 300 + (Math.random() - 0.5) * 300, vx: 0, vy: 0, radius: Math.max(6, Math.min(18, 6 + (n.neighbor_count || 0))) })); const nodeIdx = {}; nodes.forEach((n, i) => { nodeIdx[n.pubkey] = i; }); const edges = (data.edges || []).filter(e => nodeIdx[e.source] !== undefined && nodeIdx[e.target] !== undefined); return { allNodes: nodes, allEdges: edges, nodes, edges, nodeIdx, stats: data.stats || {}, zoom: 1, panX: 0, panY: 0, dragging: null, panning: false, lastMouseX: 0, lastMouseY: 0, cooling: 1.0, animId: null }; } function applyNGFilters() { if (!_ngState) return; const minScore = parseInt(document.getElementById('ngMinScore').value, 10) / 100; const conf = document.getElementById('ngConfidence').value; const checkedRoles = new Set(); document.querySelectorAll('#ngRoleChecks input:checked').forEach(cb => checkedRoles.add(cb.dataset.role)); // Filter nodes by role const visibleNodes = _ngState.allNodes.filter(n => { const role = (n.role || 'unknown').toLowerCase(); return checkedRoles.has(role) || role === 'unknown' || role === 'observer'; }); const visiblePKs = new Set(visibleNodes.map(n => n.pubkey)); // Filter edges _ngState.edges = _ngState.allEdges.filter(e => { if (e.score < minScore) return false; if (conf === 'high' && (e.ambiguous || e.score < 0.5)) return false; if (conf === 'hide-ambiguous' && e.ambiguous) return false; return visiblePKs.has(e.source) && visiblePKs.has(e.target); }); // Only include nodes that have at least one visible edge const edgeNodes = new Set(); _ngState.edges.forEach(e => { edgeNodes.add(e.source); edgeNodes.add(e.target); }); _ngState.nodes = visibleNodes.filter(n => edgeNodes.has(n.pubkey)); // Rebuild index _ngState.nodeIdx = {}; _ngState.nodes.forEach((n, i) => { _ngState.nodeIdx[n.pubkey] = i; }); _ngState.cooling = 1.0; renderNGStats(_ngState); } function renderNGStats(st) { const nodes = st.nodes, edges = st.edges; const totalScore = edges.reduce((s, e) => s + e.score, 0); const avgScore = edges.length ? (totalScore / edges.length) : 0; const ambiguous = edges.filter(e => e.ambiguous).length; const resolved = edges.length ? ((edges.length - ambiguous) / edges.length * 100) : 0; const statsEl = document.getElementById('ngStats'); if (!statsEl) return; statsEl.innerHTML = `
${nodes.length}
Nodes
${edges.length}
Edges
${avgScore.toFixed(2)}
Avg Score
${resolved.toFixed(0)}%
Resolved
${ambiguous}
Ambiguous
`; // Update canvas aria-label with current graph summary var canvas = document.getElementById('ngCanvas'); if (canvas) { canvas.setAttribute('aria-label', 'Neighbor affinity graph: ' + nodes.length + ' nodes, ' + edges.length + ' edges, ' + resolved.toFixed(0) + '% resolved. Use arrow keys to pan, +/- to zoom, 0 to reset.'); } // Update accessible text list updateNGTextList(st); } function updateNGTextList(st) { var listEl = document.getElementById('ngTextList'); if (!listEl) return; var nodes = st.nodes, edges = st.edges; if (nodes.length === 0) { listEl.innerHTML = '

No nodes to display.

'; return; } // Build adjacency for text list var adj = {}; edges.forEach(function(e) { if (!adj[e.source]) adj[e.source] = []; if (!adj[e.target]) adj[e.target] = []; adj[e.source].push({ pk: e.target, score: e.score, ambiguous: e.ambiguous }); adj[e.target].push({ pk: e.source, score: e.score, ambiguous: e.ambiguous }); }); var nodeMap = {}; nodes.forEach(function(n) { nodeMap[n.pubkey] = n; }); var html = ''; nodes.slice().sort(function(a, b) { return (a.name || a.pubkey).localeCompare(b.name || b.pubkey); }).forEach(function(n) { var neighbors = (adj[n.pubkey] || []).map(function(nb) { var peer = nodeMap[nb.pk]; var name = peer ? (peer.name || nb.pk.slice(0, 8)) : nb.pk.slice(0, 8); var conf = nb.ambiguous ? ' ⚠' : (nb.score >= 0.5 ? ' ●' : ' β—‹'); return esc(name) + conf; }).join(', '); html += ''; }); html += '
NodeRoleNeighbors
' + esc(n.name || n.pubkey.slice(0, 12)) + '' + esc(n.role || 'unknown') + '' + (neighbors || 'none') + '
'; html += '

● = high confidence (score β‰₯ 0.5), β—‹ = low confidence, ⚠ = ambiguous/unresolved

'; listEl.innerHTML = html; } function startGraphRenderer() { if (!_ngState) return; // Node count guard: skip force simulation for very large graphs var NODE_LIMIT = 1000; if (_ngState.allNodes.length > NODE_LIMIT) { var el = document.getElementById('ngCanvas'); if (el) { el.style.display = 'none'; var msg = document.createElement('div'); msg.className = 'analytics-card'; msg.innerHTML = '

Graph has ' + _ngState.allNodes.length + ' nodes (limit: ' + NODE_LIMIT + '). Force simulation skipped for performance. Use filters to reduce the node count.

'; el.parentNode.insertBefore(msg, el); } return; } const canvas = document.getElementById('ngCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.scale(dpr, dpr); const W = canvas.clientWidth, H = canvas.clientHeight; // Interaction let hoverNode = null; function canvasToGraph(cx, cy) { return { x: (cx - _ngState.panX) / _ngState.zoom, y: (cy - _ngState.panY) / _ngState.zoom }; } function findNode(cx, cy) { const gp = canvasToGraph(cx, cy); for (let i = _ngState.nodes.length - 1; i >= 0; i--) { const n = _ngState.nodes[i]; const dx = gp.x - n.x, dy = gp.y - n.y; if (dx * dx + dy * dy <= n.radius * n.radius) return n; } return null; } canvas.addEventListener('mousedown', function(e) { const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const n = findNode(cx, cy); if (n) { _ngState.dragging = n; n._pinned = true; canvas.style.cursor = 'grabbing'; } else { _ngState.panning = true; canvas.style.cursor = 'grabbing'; } _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; }); canvas.addEventListener('mousemove', function(e) { const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; if (_ngState.dragging) { const dx = (e.clientX - _ngState.lastMouseX) / _ngState.zoom; const dy = (e.clientY - _ngState.lastMouseY) / _ngState.zoom; _ngState.dragging.x += dx; _ngState.dragging.y += dy; _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; _ngState.cooling = Math.max(_ngState.cooling, 0.3); } else if (_ngState.panning) { _ngState.panX += e.clientX - _ngState.lastMouseX; _ngState.panY += e.clientY - _ngState.lastMouseY; _ngState.lastMouseX = e.clientX; _ngState.lastMouseY = e.clientY; } else { const n = findNode(cx, cy); if (n !== hoverNode) { hoverNode = n; canvas.style.cursor = n ? 'pointer' : 'grab'; const tip = document.getElementById('ngTooltip'); if (n && tip) { tip.style.display = 'block'; tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; tip.innerHTML = `${esc(n.name || n.pubkey.slice(0, 12) + '…')}
Role: ${esc(n.role || 'unknown')}
Neighbors: ${n.neighbor_count || 0}`; } else if (tip) { tip.style.display = 'none'; } } else if (hoverNode) { const tip = document.getElementById('ngTooltip'); if (tip) { tip.style.left = (cx + 12) + 'px'; tip.style.top = (cy - 8) + 'px'; } } } }); canvas.addEventListener('mouseup', function() { if (_ngState.dragging) { _ngState.dragging._pinned = false; _ngState._wasDragging = true; } _ngState.dragging = null; _ngState.panning = false; canvas.style.cursor = hoverNode ? 'pointer' : 'grab'; }); canvas.addEventListener('mouseleave', function() { _ngState.dragging = null; _ngState.panning = false; _ngState._wasDragging = false; const tip = document.getElementById('ngTooltip'); if (tip) tip.style.display = 'none'; hoverNode = null; }); canvas.addEventListener('click', function(e) { if (_ngState._wasDragging) { _ngState._wasDragging = false; return; } if (_ngState.dragging) return; const rect = canvas.getBoundingClientRect(); const n = findNode(e.clientX - rect.left, e.clientY - rect.top); if (n) location.hash = '#/nodes/' + n.pubkey; }); canvas.addEventListener('keydown', function(e) { const PAN_STEP = 30, ZOOM_STEP = 1.15; switch (e.key) { case 'ArrowLeft': _ngState.panX += PAN_STEP; e.preventDefault(); break; case 'ArrowRight': _ngState.panX -= PAN_STEP; e.preventDefault(); break; case 'ArrowUp': _ngState.panY += PAN_STEP; e.preventDefault(); break; case 'ArrowDown': _ngState.panY -= PAN_STEP; e.preventDefault(); break; case '+': case '=': _ngState.zoom = Math.min(10, _ngState.zoom * ZOOM_STEP); e.preventDefault(); break; case '-': case '_': _ngState.zoom = Math.max(0.1, _ngState.zoom / ZOOM_STEP); e.preventDefault(); break; case '0': _ngState.zoom = 1; _ngState.panX = 0; _ngState.panY = 0; e.preventDefault(); break; } }); canvas.addEventListener('wheel', function(e) { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const cx = e.clientX - rect.left, cy = e.clientY - rect.top; const factor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.max(0.1, Math.min(10, _ngState.zoom * factor)); // Zoom towards mouse position _ngState.panX = cx - (cx - _ngState.panX) * (newZoom / _ngState.zoom); _ngState.panY = cy - (cy - _ngState.panY) * (newZoom / _ngState.zoom); _ngState.zoom = newZoom; }, { passive: false }); // Cache text color to avoid getComputedStyle every frame const _labelColor = cssVar('--text-primary') || '#e0e0e0'; // Force simulation + render loop // Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1) var _perfFrameTimes = [], _perfLastTime = 0; function tick() { if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; } var now = performance.now(); if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime); _perfLastTime = now; if (_perfFrameTimes.length === 100) { var avg = _perfFrameTimes.reduce(function(a, b) { return a + b; }, 0) / 100; console.log('[NeighborGraph perf] avg frame time over 100 frames: ' + avg.toFixed(2) + 'ms (' + (1000 / avg).toFixed(0) + ' fps)'); _perfFrameTimes = []; } const st = _ngState; const nodes = st.nodes, edges = st.edges, idx = st.nodeIdx; if (st.cooling > 0.001) { // Repulsion (all pairs β€” use grid for large sets, brute force for small) const k = 80; // repulsion constant for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { let dx = nodes[j].x - nodes[i].x; let dy = nodes[j].y - nodes[i].y; let d2 = dx * dx + dy * dy; if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; } const f = k * k / d2; const fx = dx / Math.sqrt(d2) * f; const fy = dy / Math.sqrt(d2) * f; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // Attraction along edges const idealLen = 120; for (const e of edges) { const si = idx[e.source], ti = idx[e.target]; if (si === undefined || ti === undefined) continue; const a = nodes[si], b = nodes[ti]; let dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - idealLen) * 0.05 * (0.5 + e.score * 0.5); const fx = dx / d * f, fy = dy / d * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Center gravity for (const n of nodes) { n.vx += (W / 2 - n.x) * 0.001; n.vy += (H / 2 - n.y) * 0.001; } // Apply velocities with damping const damping = 0.85; for (const n of nodes) { if (n._pinned) { n.vx = 0; n.vy = 0; continue; } n.vx *= damping * st.cooling; n.vy *= damping * st.cooling; const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy); if (speed > 10) { n.vx *= 10 / speed; n.vy *= 10 / speed; } n.x += n.vx; n.y += n.vy; } st.cooling *= 0.995; } // Render ctx.save(); ctx.clearRect(0, 0, W, H); ctx.translate(st.panX, st.panY); ctx.scale(st.zoom, st.zoom); // Edges for (const e of edges) { const si = idx[e.source], ti = idx[e.target]; if (si === undefined || ti === undefined) continue; const a = nodes[si], b = nodes[ti]; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = e.ambiguous ? 'rgba(255,200,0,0.4)' : 'rgba(150,150,150,0.35)'; ctx.lineWidth = Math.max(0.5, e.score * 4); if (e.ambiguous) { ctx.setLineDash([4, 4]); } else { ctx.setLineDash([]); } ctx.stroke(); ctx.setLineDash([]); } // Nodes const roleColors = window.ROLE_COLORS || {}; for (const n of nodes) { const color = roleColors[(n.role || '').toLowerCase()] || '#6b7280'; ctx.beginPath(); ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); if (n === hoverNode) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } // Label const label = n.name || (n.pubkey ? n.pubkey.slice(0, 8) + '…' : ''); if (label && st.zoom > 0.4) { ctx.fillStyle = _labelColor; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(label, n.x, n.y + n.radius + 12); } } ctx.restore(); st.animId = requestAnimationFrame(tick); } _ngState.animId = requestAnimationFrame(tick); } registerPage('analytics', { init, destroy }); })();