/* === 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 = `
`;
// 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 =
`Failed to load: ${e.message}
`;
}
}
async function renderTab(tab) {
const el = document.getElementById('analyticsContent');
const d = _analyticsData;
switch (tab) {
case 'overview': renderOverview(el, d); break;
case 'rf': renderRF(el, d.rfData); break;
case 'topology': renderTopology(el, d.topoData); break;
case 'channels': renderChannels(el, d.chanData); break;
case 'hashsizes': renderHashSizes(el, d.hashData); break;
case 'collisions': await renderCollisionTab(el, d.hashData, 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 = 'Type Packets Avg SNR Min Max Distribution ';
snrByType.forEach(t => {
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
html += `
${t.name}
${t.count}
${sf(t.avg, 1)} dB
${sf(t.min, 1)}
${sf(t.max, 1)}
`;
});
return html + '
';
}
function renderSignalTimeline(data) {
if (!data.length) return 'No data
';
const w = 400, h = 160, pad = 35;
const maxPkts = Math.max(...data.map(d => d.count), 1);
let svg = `Signal quality over time showing SNR trend and packet volume `;
const snrPts = data.map((d, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
return `${x},${y}`;
}).join(' ');
svg += ` `;
// Packet count as area
const areaPts = data.map((d, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
const y = h - pad - (d.count / maxPkts) * (h - pad * 2) * 0.4;
return `${x},${y}`;
});
const baseline = data.map((_, i) => {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
return `${x},${h - pad}`;
});
svg += ` `;
// Labels
const step = Math.max(1, Math.floor(data.length / 6));
for (let i = 0; i < data.length; i += step) {
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
svg += `${data[i].hour.slice(11)}h `;
}
svg += ' ';
svg += ` Avg SNR Volume
`;
return svg;
}
// ===================== TOPOLOGY =====================
function renderTopology(el, topo) {
el.innerHTML = `
π Hop Count Distribution
Number of repeater hops per packet
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
Avg: ${sf(topo.avgHops, 1)} hops
Median: ${topo.medianHops}
Max: ${topo.maxHops}
1-hop direct: ${topo.hopDistribution[0]?.count || 0}
πΈοΈ Top Repeaters
Nodes appearing most in packet paths
${renderRepeaterTable(topo.topRepeaters)}
π€ Repeater Pair Heatmap
Which repeaters frequently appear together in paths
${renderPairTable(topo.topPairs)}
π Hops vs SNR
Does more hops = worse signal?
${renderHopsSNR(topo.hopsVsSnr)}
π Best Path to Each Node
Shortest hop distance seen across all observers
${renderBestPath(topo.bestPathList)}
π Per-Observer Reachability
Nodes at each hop distance, from each observer's perspective
${topo.observers.length > 1 ? `
${topo.observers.map((o, i) => `${esc(o.name)} `).join('')}
All Observers
` : ''}
${renderPerObserverReach(topo.perObserverReach, topo.observers[0]?.id)}
${topo.multiObsNodes.length ? `
π Cross-Observer Comparison
Nodes seen by multiple observers β hop distance varies by vantage point
${renderCrossObserver(topo.multiObsNodes)}
` : ''}
`;
// Observer selector event handling
const selector = document.getElementById('obsSelector');
if (selector) {
initTabBar(selector);
selector.addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
selector.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const obsId = btn.dataset.obs;
document.getElementById('reachContent').innerHTML =
obsId === '__all' ? renderAllObserversReach(topo.perObserverReach) : renderPerObserverReach(topo.perObserverReach, obsId);
});
}
}
function renderRepeaterTable(repeaters) {
if (!repeaters.length) return 'No data
';
const max = repeaters[0].count;
let html = '';
repeaters.slice(0, 15).forEach(r => {
const pct = (r.count / max * 100).toFixed(0);
html += `
${r.name ? '' + esc(r.name) + ' ' : '' + r.hop + ' '}
${r.count.toLocaleString()}
`;
});
return html + '
';
}
function renderPairTable(pairs) {
if (!pairs.length) return 'Not enough multi-hop data
';
let html = 'Node A Node B Co-appearances ';
pairs.slice(0, 12).forEach(p => {
html += `
${p.nameA ? `${esc(p.nameA)} ` : `${p.hopA} `}
${p.nameB ? `${esc(p.nameB)} ` : `${p.hopB} `}
${p.count}
`;
});
return html + '
';
}
function renderHopsSNR(data) {
if (!data.length) return 'No data
';
const w = 380, h = 160, pad = 40;
const maxHop = Math.max(...data.map(d => d.hops));
let svg = `Hops vs SNR bubble chart showing signal degradation over distance `;
data.forEach(d => {
const x = pad + (d.hops / maxHop) * (w - pad * 2);
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
svg += ` `;
svg += `${d.hops}h `;
});
svg += `Hops `;
svg += `Avg SNR `;
svg += ' ';
return svg;
}
function renderPerObserverReach(perObserverReach, obsId) {
const data = perObserverReach[obsId];
if (!data || !data.rings.length) return 'No path data for this observer
';
let html = ``;
data.rings.forEach(ring => {
const opacity = Math.max(0.3, 1 - ring.hops * 0.06);
const nodeLinks = ring.nodes.slice(0, 8).map(n => {
const label = n.name ? `
${esc(n.name)} ` : `
${n.hop} `;
const detail = n.distRange ? `
(${n.distRange}) ` : '';
return label + detail;
}).join(', ');
const extra = ring.nodes.length > 8 ? `
+${ring.nodes.length - 8} more ` : '';
html += `
${ring.hops} hop${ring.hops > 1 ? 's' : ''}
${nodeLinks}${extra}
${ring.nodes.length} node${ring.nodes.length > 1 ? 's' : ''}
`;
});
return html + '
';
}
function renderAllObserversReach(perObserverReach) {
let html = '';
for (const [obsId, data] of Object.entries(perObserverReach)) {
html += `π‘ ${esc(data.observer_name)} `;
html += renderPerObserverReach(perObserverReach, obsId);
}
return html || 'No data
';
}
function renderCrossObserver(nodes) {
if (!nodes.length) return 'No nodes seen by multiple observers
';
let html = `
Node Observers Hop Distances `;
nodes.forEach(n => {
const name = n.name
? `${esc(n.name)} `
: `${n.hop} `;
const obsInfo = n.observers.map(o =>
`${esc(o.observer_name)}: ${o.minDist} hop${o.minDist > 1 ? 's' : ''} (${o.count} pkts) `
).join(' ');
html += `${name} ${n.observers.length} ${obsInfo} `;
});
return html + '
';
}
function renderBestPath(nodes) {
if (!nodes.length) return 'No data
';
// Group by distance for a cleaner view
const byDist = {};
nodes.forEach(n => {
if (!byDist[n.minDist]) byDist[n.minDist] = [];
byDist[n.minDist].push(n);
});
let html = '';
Object.entries(byDist).sort((a, b) => +a[0] - +b[0]).forEach(([dist, nodes]) => {
const opacity = Math.max(0.3, 1 - (+dist) * 0.06);
const nodeLinks = nodes.slice(0, 10).map(n => {
const label = n.name
? `
${esc(n.name)} `
: `
${n.hop} `;
return label + `
via ${esc(n.observer_name)} `;
}).join(', ');
const extra = nodes.length > 10 ? `
+${nodes.length - 10} more ` : '';
html += `
${dist} hop${+dist > 1 ? 's' : ''}
${nodeLinks}${extra}
${nodes.length} node${nodes.length > 1 ? 's' : ''}
`;
});
return html + '
';
}
// ===================== CHANNELS =====================
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 ? `
Node Hash Size Adverts Last Seen
${data.multiByteNodes.map(n => `
${esc(n.name)}
${n.hashSize}-byte
${n.packets}
${timeAgo(n.lastSeen)}
`).join('')}
` : '
No multi-byte adopters found
'}
Top Path Hops
Hop Node Bytes Appearances
${data.topHops.map(h => {
const link = h.pubkey ? `#/nodes/${encodeURIComponent(h.pubkey)}` : `#/packets?search=${h.hex}`;
return `
${h.hex}
${h.name ? `${esc(h.name)} ` : 'unknown '}
${h.size}-byte
${h.count.toLocaleString()}
`;
}).join('')}
`;
}
async function renderCollisionTab(el, data, collisionData) {
el.innerHTML = `
β οΈ Inconsistent Sizes
|
π’ Hash Matrix
|
π₯ Collision Risk
β οΈ Inconsistent Hash Sizes β top
Nodes sending adverts with varying hash sizes. Caused by a bug where automatic adverts ignored the configured multibyte path setting. Fixed in repeater v1.14.1 .
1-Byte
2-Byte
3-Byte
Click a cell to see which nodes share that prefix.
`;
// 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 = `
Node Role Current Hash Sizes Seen
${inconsistent.map((n, i) => {
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
const sizeBadges = (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 `
${esc(n.name || n.public_key.slice(0, 12))}
${n.role}
${prefix} (${n.hash_size || '?'}B)
${sizeBadges}
`;
}).join('')}
${inconsistent.length} node${inconsistent.length > 1 ? 's' : ''} affected. Click a node name to see which adverts have different hash sizes.
`;
}
}
// 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-byte 2-byte 3-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 = `';
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
`
: `
0x${hex}
${count} nodes β COLLISION
`;
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 `
`;
}).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'}
`;
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 `
`;
}).join('')}
`;
} else {
dhtml += ``;
for (const [twoHex, nodes] of Object.entries(info.two_byte_map || {}).sort()) {
const isCollision = nodes.length > 1;
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 = `
Prefix
${showAppearances ? 'Appearances ' : ''}
Max Distance
Assessment
Colliding Nodes
${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
badge = 'ποΈ Local ';
tooltip = 'Nodes close enough for direct RF β probably genuine prefix collision';
} else if (c.classification === 'regional') {
badge = 'β‘ Regional ';
tooltip = 'At edge of 915MHz range β could indicate atmospheric ducting or hilltop-to-hilltop links';
} else if (c.classification === 'distant') {
badge = 'π Distant ';
tooltip = 'Beyond typical LoRa range β likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
} else {
badge = 'β Unknown ';
tooltip = 'Not enough coordinate data to classify';
}
const nodes = c.nodes || [];
const distStr = c.with_coords >= 2 ? `${Math.round(c.max_dist_km)} km` : 'β ';
return `
${c.prefix}
${showAppearances ? `${(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(' ')}
`;
}).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
# Route Occurrences % of paths Frequency
${data.subpaths.map((s, i) => {
const barW = Math.max(2, Math.round(s.count / maxCount * 100));
const hops = s.path.split(' β ');
const rawHops = s.rawHops || [];
const hasSelfLoop = hops.some((h, j) => j > 0 && h === hops[j - 1]);
const routeDisplay = hops.map(h => esc(h)).join(' β ');
const prefixDisplay = rawHops.join(' β ');
return `
${i + 1}
${routeDisplay}${hasSelfLoop ? ' π ' : ''}${esc(prefixDisplay)}
${s.count.toLocaleString()}
${s.pct}%
`;
}).join('')}
`;
}
el.innerHTML = `
π€οΈ Route Pattern Analysis
Click a route to see details. Most common subpaths β reveals backbone routes, bottlenecks, and preferred relay chains.
Hide likely prefix collisions (self-loops)
${renderTable(d2, 'Pairs (2-hop links)')}
${renderTable(d3, 'Triples (3-hop chains)')}
${renderTable(d4, 'Quads (4-hop chains)')}
${renderTable(d5, 'Long chains (5+ hops)')}
Select a route to view details
`;
// Click handler for rows
el.addEventListener('click', e => {
const tr = e.target.closest('tr[data-hops]');
if (!tr) return;
el.querySelectorAll('tr.subpath-selected').forEach(r => r.classList.remove('subpath-selected'));
tr.classList.add('subpath-selected');
loadSubpathDetail(tr.dataset.hops);
});
// Jump nav β scroll within list panel
el.querySelectorAll('.subpath-jump-nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const target = document.getElementById(a.getAttribute('href').slice(1));
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
// Collision toggle
const toggle = document.getElementById('hideCollisions');
function applyCollisionFilter() {
const hide = toggle.checked;
localStorage.setItem('subpath-hide-collisions', hide ? '1' : '0');
el.querySelectorAll('tr.subpath-selfloop').forEach(r => r.style.display = hide ? 'none' : '');
}
toggle.addEventListener('change', applyCollisionFilter);
applyCollisionFilter();
} catch (e) {
el.innerHTML = `Error loading subpath data: ${e.message}
`;
}
}
async function loadSubpathDetail(hopsStr) {
const panel = document.getElementById('subpathDetail');
panel.classList.remove('collapsed');
panel.innerHTML = 'Loadingβ¦
';
try {
const data = await api('/analytics/subpath-detail?hops=' + encodeURIComponent(hopsStr), { ttl: CLIENT_TTL.analyticsRF });
renderSubpathDetail(panel, data);
} catch (e) {
panel.innerHTML = `Error: ${e.message}
`;
}
}
function renderSubpathDetail(panel, data) {
const nodesWithLoc = data.nodes.filter(n => n.lat && n.lon && !(n.lat === 0 && n.lon === 0));
const hasMap = nodesWithLoc.length >= 2;
const maxHour = Math.max(...data.hourDistribution, 1);
panel.innerHTML = `
${data.nodes.map(n => esc(n.name)).join(' β ')}
${data.hops.join(' β ')}
${data.totalMatches.toLocaleString()} occurrences
${nodesWithLoc.length >= 2 ? `
π Hop Distances
${(() => {
const dists = [];
let total = 0;
for (let i = 0; i < data.nodes.length - 1; i++) {
const a = data.nodes[i], b = data.nodes[i+1];
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
const 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('')}
0 6 12 18 23
β±οΈ Timeline
First seen: ${data.firstSeen ? new Date(data.firstSeen).toLocaleString() : 'β'}
Last seen: ${data.lastSeen ? new Date(data.lastSeen).toLocaleString() : 'β'}
${data.observers.length ? `
ποΈ Observers
${data.observers.map(o => `
${esc(o.name)}: ${o.count}
`).join('')}
` : ''}
${data.parentPaths.length ? `
π Full Paths Containing This Route
${data.parentPaths.map(p => `
${esc(p.path)} Γ${p.count}
`).join('')}
` : ''}
`;
// Render minimap
if (hasMap && typeof L !== 'undefined') {
const map = L.map('subpathMap', { zoomControl: false, attributionControl: false });
L.tileLayer(getTileUrl(), { maxZoom: 18 }).addTo(map);
const latlngs = [];
nodesWithLoc.forEach((n, i) => {
const ll = [n.lat, n.lon];
latlngs.push(ll);
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
L.circleMarker(ll, {
radius: isEnd ? 8 : 5,
color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
fillOpacity: 0.9, weight: 2
}).bindTooltip(n.name, { permanent: false }).addTo(map);
});
L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
}
}
async function renderNodesTab(el) {
el.innerHTML = 'Loading node analyticsβ¦
';
try {
const rq = RegionFilter.regionQueryString();
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
]);
const nodes = nodesResp.nodes || nodesResp;
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
const myKeys = new Set(myNodes.map(n => n.pubkey));
// Map bulk health by pubkey
const healthMap = {};
bulkHealth.forEach(h => { healthMap[h.public_key] = h; });
const enriched = nodes.filter(n => healthMap[n.public_key]).map(n => ({ ...n, health: { stats: healthMap[n.public_key].stats, observers: healthMap[n.public_key].observers } }));
// Compute rankings
const byPackets = [...enriched].sort((a, b) => (b.health.stats.totalTransmissions || b.health.stats.totalPackets || 0) - (a.health.stats.totalTransmissions || a.health.stats.totalPackets || 0));
const bySnr = [...enriched].filter(n => n.health.stats.avgSnr != null).sort((a, b) => b.health.stats.avgSnr - a.health.stats.avgSnr);
const byObservers = [...enriched].sort((a, b) => (b.health.observers?.length || 0) - (a.health.observers?.length || 0));
const byRecent = [...enriched].filter(n => n.health.stats.lastHeard).sort((a, b) => new Date(b.health.stats.lastHeard) - new Date(a.health.stats.lastHeard));
// Use server-computed status across ALL nodes
const { active, degraded, silent, total: totalNodes, roleCounts } = netStatus;
function nodeLink(n) {
return `${esc(n.name || n.public_key.slice(0, 12))} `;
}
function claimedBadge(n) {
return myKeys.has(n.public_key) ? ' β
MINE ' : '';
}
// ROLE_COLORS from shared roles.js
el.innerHTML = `
π Network Status
${degraded}
π‘ Degraded
${totalNodes}
Total Nodes
π Role Breakdown
${Object.entries(roleCounts).sort((a,b) => b[1]-a[1]).map(([role, count]) => {
const c = ROLE_COLORS[role] || '#6b7280';
return `${role}: ${count} `;
}).join('')}
${myKeys.size ? `
β My Claimed Nodes
Node Role Packets Avg SNR Observers Last Heard
${enriched.filter(n => myKeys.has(n.public_key)).map(n => {
const s = n.health.stats;
return `
${nodeLink(n)}
${n.role}
${s.totalTransmissions || s.totalPackets || 0}
${s.avgSnr != null ? s.avgSnr.toFixed(1) + ' dB' : 'β'}
${n.health.observers?.length || 0}
${s.lastHeard ? timeAgo(s.lastHeard) : 'β'}
`;
}).join('') || 'No claimed nodes have health data '}
` : ''}
π Most Active Nodes
# Node Role Total Packets Packets Today Analytics
${byPackets.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.stats.totalTransmissions || n.health.stats.totalPackets || 0}
${n.health.stats.packetsToday || 0}
π
`).join('')}
πΆ Best Signal Quality
# Node Role Avg SNR Observers Analytics
${bySnr.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.stats.avgSnr.toFixed(1)} dB
${n.health.observers?.length || 0}
π
`).join('')}
π Most Observed Nodes
# Node Role Observers Avg SNR Analytics
${byObservers.slice(0, 15).map((n, i) => `
${i + 1}
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${n.health.observers?.length || 0}
${n.health.stats.avgSnr != null ? n.health.stats.avgSnr.toFixed(1) + ' dB' : 'β'}
π
`).join('')}
β° Recently Active
Node Role Last Heard Packets Today Analytics
${byRecent.slice(0, 15).map(n => `
${nodeLink(n)}${claimedBadge(n)}
${n.role}
${timeAgo(n.health.stats.lastHeard)}
${n.health.stats.packetsToday || 0}
π
`).join('')}
`;
} catch (e) {
el.innerHTML = `Failed to load node analytics: ${esc(e.message)}
`;
}
}
async function renderDistanceTab(el) {
try {
const rqs = RegionFilter.regionQueryString();
const sep = rqs ? '?' + rqs.slice(1) : '';
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
const s = data.summary;
let html = `
${s.totalHops.toLocaleString()}
Total Hops Analyzed
${s.totalPaths.toLocaleString()}
Paths Analyzed
${s.avgDist} km
Avg Hop Distance
${s.maxDist} km
Max Hop Distance
`;
// Category stats
const cats = data.catStats;
html += `Distance by Link Type Type Count Avg (km) Median (km) Min (km) Max (km) `;
for (const [cat, st] of Object.entries(cats)) {
if (!st.count) continue;
html += `${esc(cat)} ${st.count.toLocaleString()} ${st.avg} ${st.median} ${st.min} ${st.max} `;
}
html += `
`;
// Histogram
if (data.distHistogram && data.distHistogram.bins) {
const buckets = data.distHistogram.bins.map(b => b.count);
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
html += `
Hop Distance Distribution ${barChart(buckets, labels, statusGreen())}`;
}
// Distance over time
if (data.distOverTime && data.distOverTime.length > 1) {
html += `
Average Distance Over Time ${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}`;
}
// Top hops leaderboard
html += `π Top 20 Longest Hops # From To Distance (km) Type SNR Packet `;
const top20 = data.topHops.slice(0, 20);
top20.forEach((h, i) => {
const fromLink = h.fromPk ? `${esc(h.fromName)} ` : esc(h.fromName || '?');
const toLink = h.toPk ? `${esc(h.toName)} ` : esc(h.toName || '?');
const snr = h.snr != null ? h.snr + ' dB' : 'β ';
const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}β¦ ` : 'β';
const mapBtn = h.fromPk && h.toPk ? `πΊοΈ ` : '';
html += `${i+1} ${fromLink} ${toLink} ${h.dist} ${esc(h.type)} ${snr} ${pktLink} ${mapBtn} `;
});
html += `
`;
// Top paths
if (data.topPaths.length) {
html += `π€οΈ Top 10 Longest Multi-Hop Paths # Total Distance (km) Hops Route Packet `;
data.topPaths.slice(0, 10).forEach((p, i) => {
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' β ');
const pktLink = p.hash ? `${esc(p.hash.slice(0, 12))}β¦ ` : 'β';
// Collect all unique pubkeys in path order
const pathPks = [];
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
const mapBtn = pathPks.length >= 2 ? `πΊοΈ ` : '';
html += `${i+1} ${p.totalDist} ${p.hopCount} ${route} ${pktLink} ${mapBtn} `;
});
html += `
`;
}
el.innerHTML = html;
// Wire up map buttons
el.querySelectorAll('.dist-map-hop').forEach(btn => {
btn.addEventListener('click', () => {
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] }));
window.location.hash = '#/map?route=1';
});
});
el.querySelectorAll('.dist-map-path').forEach(btn => {
btn.addEventListener('click', () => {
try {
const hops = JSON.parse(btn.dataset.hops);
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops }));
window.location.hash = '#/map?route=1';
} catch {}
});
});
} catch (e) {
el.innerHTML = `Failed to load distance analytics: ${esc(e.message)}
`;
}
}
function destroy() { _analyticsData = {}; _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
Roles:
Min Score:
0.10
Confidence:
Show All
High Only
Hide Ambiguous
π 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 += ` ${esc(r)} `;
});
// 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 = `
${avgScore.toFixed(2)}
Avg Score
${resolved.toFixed(0)}%
Resolved
`;
// 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 = 'Node Role Neighbors ';
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 += '' + esc(n.name || n.pubkey.slice(0, 12)) + ' ' + esc(n.role || 'unknown') + ' ' + (neighbors || 'none ') + ' ';
});
html += '
';
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 });
})();