feat: add multi-byte hash usage matrix with stats and improved tooltips (#269)

- Add 1/2/3-byte selector to Hash Issues analytics page
- 1-byte and 2-byte modes show 16×16 matrix with stat cards (nodes
tracked, using N-byte ID, prefix space used, prefix collisions)
- 3-byte mode shows summary stat cards instead of unrenderable grid
- Fix "Nodes tracked" to always show total node count across all modes
- Use CSS variable colours for matrix cells (light/dark mode compatible)
- Replace native title tooltips with custom styled popovers
- Hide collision risk card when 3-byte mode is selected
- Fix double-tooltip bug on mode switch via _matrixTipInit guard
- Fix tooltip persisting outside matrix grid on mouseleave

https://dev.ve7kod.ca/#/analytics

Hash Issues

---------

Co-authored-by: Jesse <your@email.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VE7KOD
2026-03-30 16:31:35 +00:00
committed by GitHub
co-authored by Jesse Claude Sonnet 4.6
parent 8f833f64ae
commit faca80e626
4 changed files with 554 additions and 83 deletions
+388 -81
View File
@@ -960,13 +960,23 @@
</div>
<div class="analytics-card" id="hashMatrixSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">🔢 1-Byte Hash Usage Matrix</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<p class="text-muted" style="margin:4px 0 8px;font-size:0.8em">Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.</p>
<div style="display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0" id="hashMatrixTitle">🔢 Hash Usage Matrix</h3>
<a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a>
</div>
<div style="display:flex;align-items:center;gap:16px;margin:8px 0">
<div class="hash-byte-selector" id="hashByteSelector" style="display:flex;gap:4px">
<button class="hash-byte-btn active" data-bytes="1">1-Byte</button>
<button class="hash-byte-btn" data-bytes="2">2-Byte</button>
<button class="hash-byte-btn" data-bytes="3">3-Byte</button>
</div>
<p class="text-muted" id="hashMatrixDesc" style="margin:0;font-size:0.8em">Click a cell to see which nodes share that prefix.</p>
</div>
<div id="hashMatrix"></div>
</div>
<div class="analytics-card" id="collisionRiskSection">
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0">💥 1-Byte Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div style="display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0" id="collisionRiskTitle">💥 Collision Risk</h3><a href="#/analytics?tab=collisions" style="font-size:11px;color:var(--text-muted)">↑ top</a></div>
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
</div>
`;
@@ -1003,10 +1013,43 @@
}
}
// Only repeaters matter for routing — filter out non-repeaters for collision analysis
// Repeaters are confirmed routing nodes; null-role nodes may also route (possible conflict)
const repeaterNodes = allNodes.filter(n => n.role === 'repeater');
renderHashMatrix(data.topHops, repeaterNodes);
renderCollisions(data.topHops, repeaterNodes);
const nullRoleNodes = allNodes.filter(n => !n.role);
const routingNodes = [...repeaterNodes, ...nullRoleNodes];
let currentBytes = 1;
function refreshHashViews(bytes) {
currentBytes = bytes;
hideMatrixTip();
// Update selector button states
document.querySelectorAll('.hash-byte-btn').forEach(b => {
b.classList.toggle('active', Number(b.dataset.bytes) === bytes);
});
// Update titles and description
const matrixTitle = document.getElementById('hashMatrixTitle');
const matrixDesc = document.getElementById('hashMatrixDesc');
const riskTitle = document.getElementById('collisionRiskTitle');
if (matrixTitle) matrixTitle.textContent = bytes === 3 ? '🔢 Hash Usage Matrix' : `🔢 ${bytes}-Byte Hash Usage Matrix`;
if (riskTitle) riskTitle.textContent = `💥 ${bytes}-Byte Collision Risk`;
if (matrixDesc) {
if (bytes === 1) matrixDesc.textContent = 'Click a cell to see which nodes share that 1-byte prefix.';
else if (bytes === 2) matrixDesc.textContent = 'Each cell = first-byte group. Color shows worst 2-byte collision within. Click a cell to see the breakdown.';
else matrixDesc.textContent = '3-byte prefix space is too large to visualize as a matrix — collision table is shown below.';
}
renderHashMatrix(data.topHops, routingNodes, bytes, allNodes);
// Hide collision risk card for 3-byte — stats are shown in the matrix panel
const riskCard = document.getElementById('collisionRiskSection');
if (riskCard) riskCard.style.display = bytes === 3 ? 'none' : '';
if (bytes !== 3) renderCollisions(data.topHops, routingNodes, bytes);
}
// Wire up selector
document.getElementById('hashByteSelector')?.querySelectorAll('.hash-byte-btn').forEach(btn => {
btn.addEventListener('click', () => refreshHashViews(Number(btn.dataset.bytes)));
});
refreshHashViews(1);
}
function renderHashTimeline(hourly) {
@@ -1033,93 +1076,341 @@
return svg;
}
async function renderHashMatrix(topHops, allNodes) {
// Shared hover tooltip for hash matrix cells.
// Called once per container — reads content from data-tip on each <td>.
// Single shared tooltip element for the entire hash matrix — avoids DOM accumulation on mode switch
let _matrixTip = null;
function getMatrixTip() {
if (!_matrixTip) {
_matrixTip = document.createElement('div');
_matrixTip.className = 'hash-matrix-tooltip';
_matrixTip.style.display = 'none';
document.body.appendChild(_matrixTip);
}
return _matrixTip;
}
function hideMatrixTip() { if (_matrixTip) _matrixTip.style.display = 'none'; }
function initMatrixTooltip(el) {
if (el._matrixTipInit) return;
el._matrixTipInit = true;
el.addEventListener('mouseover', e => {
const td = e.target.closest('td[data-tip]');
if (!td) return;
const tip = getMatrixTip();
tip.innerHTML = td.dataset.tip;
tip.style.display = 'block';
});
el.addEventListener('mousemove', e => {
if (!_matrixTip || _matrixTip.style.display === 'none') return;
const x = e.clientX + 14, y = e.clientY + 14;
_matrixTip.style.left = Math.min(x, window.innerWidth - _matrixTip.offsetWidth - 8) + 'px';
_matrixTip.style.top = Math.min(y, window.innerHeight - _matrixTip.offsetHeight - 8) + 'px';
});
el.addEventListener('mouseout', e => {
if (e.target.closest('td[data-tip]') && !e.relatedTarget?.closest('td[data-tip]')) hideMatrixTip();
});
el.addEventListener('mouseleave', hideMatrixTip);
}
// Pure data helpers — extracted for testability
function buildOneBytePrefixMap(nodes) {
const map = {};
for (let i = 0; i < 256; i++) map[i.toString(16).padStart(2, '0').toUpperCase()] = [];
for (const n of nodes) {
const hex = n.public_key.slice(0, 2).toUpperCase();
if (map[hex]) map[hex].push(n);
}
return map;
}
function buildTwoBytePrefixInfo(nodes) {
const info = {};
for (let i = 0; i < 256; i++) {
const h = i.toString(16).padStart(2, '0').toUpperCase();
info[h] = { groupNodes: [], twoByteMap: {}, maxCollision: 0, collisionCount: 0 };
}
for (const n of nodes) {
const firstHex = n.public_key.slice(0, 2).toUpperCase();
const twoHex = n.public_key.slice(0, 4).toUpperCase();
const entry = info[firstHex];
if (!entry) continue;
entry.groupNodes.push(n);
if (!entry.twoByteMap[twoHex]) entry.twoByteMap[twoHex] = [];
entry.twoByteMap[twoHex].push(n);
}
for (const entry of Object.values(info)) {
const collisions = Object.values(entry.twoByteMap).filter(v => v.length > 1);
entry.collisionCount = collisions.length;
entry.maxCollision = collisions.length ? Math.max(...collisions.map(v => v.length)) : 0;
}
return info;
}
function buildCollisionHops(allNodes, bytes) {
const map = {};
for (const n of allNodes) {
const p = n.public_key.slice(0, bytes * 2).toUpperCase();
if (!map[p]) map[p] = { hex: p, count: 0, size: bytes };
map[p].count++;
}
return Object.values(map).filter(h => h.count > 1);
}
function renderHashMatrix(topHops, allNodes, bytes, totalNodes) {
bytes = bytes || 1;
totalNodes = totalNodes || allNodes;
const el = document.getElementById('hashMatrix');
// Build prefix → node count map
const prefixNodes = {};
for (let i = 0; i < 256; i++) {
const hex = i.toString(16).padStart(2, '0').toUpperCase();
prefixNodes[hex] = allNodes.filter(n => n.public_key.toUpperCase().startsWith(hex));
// 3-byte: show a summary panel instead of a matrix
if (bytes === 3) {
const total = totalNodes.length;
const threeByteNodes = allNodes.filter(n => n.hash_size === 3).length;
const nodesForByte = allNodes.filter(n => n.hash_size === 3 || !n.hash_size);
const prefixMap = {};
for (const n of nodesForByte) {
const p = n.public_key.slice(0, 6).toUpperCase();
if (!prefixMap[p]) prefixMap[p] = 0;
prefixMap[p]++;
}
const uniquePrefixes = Object.keys(prefixMap).length;
const collisions = Object.values(prefixMap).filter(c => c > 1).length;
const spaceSize = 16777216; // 2^24
const pct = uniquePrefixes > 0 ? ((uniquePrefixes / spaceSize) * 100).toFixed(6) : '0';
el.innerHTML = `
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${total.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 3-byte ID</div>
<div class="analytics-stat-value">${threeByteNodes.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${pct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 16.7M possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${collisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${collisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${collisions}</div>
</div>
</div>
<p class="text-muted" style="margin:0;font-size:0.8em">The 3-byte prefix space (16.7M values) is too large to visualize as a grid.</p>`;
return;
}
const nibbles = '0123456789ABCDEF'.split('');
const cellSize = 36;
const headerSize = 24;
let html = `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) {
html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
}
html += '</tr>';
if (bytes === 1) {
const nodesForByte = allNodes.filter(n => n.hash_size === 1 || !n.hash_size);
const prefixNodes = buildOneBytePrefixMap(nodesForByte);
const oneByteCount = allNodes.filter(n => n.hash_size === 1).length;
const oneUsed = Object.values(prefixNodes).filter(v => v.length > 0).length;
const oneCollisions = Object.values(prefixNodes).filter(v => v.length > 1).length;
const onePct = ((oneUsed / 256) * 100).toFixed(1);
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const nodes = prefixNodes[hex] || [];
const count = nodes.length;
let bg, color;
if (count === 0) {
bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle
} else if (count === 1) {
bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision
} else {
// 2+ nodes: orange→red
const t = Math.min((count - 2) / 4, 1);
const r = Math.round(220 + 35 * t);
const g = Math.round(120 * (1 - t));
bg = `rgb(${r},${g},30)`; color = '#fff';
}
const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`;
const cellText = count === 0 ? `<span style="font-size:11px">${hex}</span>` : count >= 2 ? `<strong>${count >= 3 ? '3+' : count}</strong>` : String(count);
html += `<td class="hash-cell${count ? ' hash-active' : ''}" data-hex="${hex}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;background:${bg};color:${color};border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:13px;font-weight:${count >= 2 ? '700' : '400'}" title="0x${hex}: ${status}">${cellText}</td>`;
}
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 1-byte ID</div>
<div class="analytics-stat-value">${oneByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${onePct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">of 256 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${oneCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${oneCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
<span><span class="legend-swatch" style="background:var(--card-bg);border:1px solid var(--border)"></span> 0 — Available</span>
<span><span class="legend-swatch" style="background:#dcfce7"></span> 1 — One node</span>
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> 2 — Two nodes (collision)</span>
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> 3+ — Three+ nodes (collision)</span>
</div>`;
el.innerHTML = html;
// Click handler for cells
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const matches = prefixNodes[hex] || [];
const detail = document.getElementById('hashDetail');
if (!matches.length) {
detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`;
return;
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const nodes = prefixNodes[hex] || [];
const count = nodes.length;
const repeaterCount = nodes.filter(n => n.role === 'repeater').length;
const isCollision = count >= 2 && repeaterCount >= 2;
const isPossible = count >= 2 && !isCollision;
let cellClass, bgStyle;
if (count === 0) { cellClass = 'hash-cell-empty'; bgStyle = ''; }
else if (count === 1) { cellClass = 'hash-cell-taken'; bgStyle = ''; }
else if (isPossible) { cellClass = 'hash-cell-possible'; bgStyle = ''; }
else { const t = Math.min((count - 2) / 4, 1); bgStyle = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass = 'hash-cell-collision'; }
const nodeLabel = m => `<div style="font-size:11px">${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' <span style="opacity:0.7">(unknown role)</span>' : ''}</div>`;
const tip1 = count === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">Available</div>`
: count === 1
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">One node — no collision</div><div class="hash-matrix-tooltip-nodes">${nodeLabel(nodes[0])}</div>`
: isPossible
? `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — POSSIBLE CONFLICT</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}</div><div class="hash-matrix-tooltip-status">${count} nodes — COLLISION</div><div class="hash-matrix-tooltip-nodes">${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`<div class="hash-matrix-tooltip-status">+${nodes.length-5} more</div>`:''}</div>`;
html += `<td class="hash-cell ${cellClass}${count ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip1.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle}border:1px solid var(--border);cursor:${count ? 'pointer' : 'default'};font-size:11px;font-weight:${count >= 2 ? '700' : '400'}">${hex}</td>`;
}
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
`<div style="margin-top:8px">${matches.map(m => {
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
}).join('')}</div>`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> Available</span>
<span><span class="legend-swatch hash-cell-taken"></span> One node</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
el.innerHTML = html;
initMatrixTooltip(el);
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const matches = prefixNodes[hex] || [];
const detail = document.getElementById('hashDetail');
if (!matches.length) { detail.innerHTML = `<strong class="mono">0x${hex}</strong><br><span class="text-muted">No known nodes</span>`; return; }
detail.innerHTML = `<strong class="mono" style="font-size:1.1em">0x${hex}</strong> — ${matches.length} node${matches.length !== 1 ? 's' : ''}` +
`<div style="margin-top:8px">${matches.map(m => {
const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `<span class="text-muted" style="font-size:0.8em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>` : '<span class="text-muted" style="font-size:0.8em">(no coords)</span>';
const role = m.role ? `<span class="badge" style="font-size:0.7em;padding:1px 4px;background:var(--border)">${esc(m.role)}</span> ` : '';
return `<div style="padding:3px 0">${role}<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a> ${coords}</div>`;
}).join('')}</div>`;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
});
} else if (bytes === 2) {
// 2-byte mode: 16×16 grid of first-byte groups
const nodesForByte = allNodes.filter(n => n.hash_size === 2 || !n.hash_size);
const firstByteInfo = buildTwoBytePrefixInfo(nodesForByte);
const twoByteCount = allNodes.filter(n => n.hash_size === 2).length;
const uniqueTwoBytePrefixes = new Set(nodesForByte.map(n => n.public_key.slice(0, 4).toUpperCase())).size;
const twoCollisions = Object.values(firstByteInfo).filter(v => v.collisionCount > 0).length;
const twoPct = ((uniqueTwoBytePrefixes / 65536) * 100).toFixed(3);
let html = `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Nodes tracked</div>
<div class="analytics-stat-value">${totalNodes.length.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Using 2-byte ID</div>
<div class="analytics-stat-value">${twoByteCount.toLocaleString()}</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px">
<div class="analytics-stat-label">Prefix space used</div>
<div class="analytics-stat-value" style="font-size:16px">${twoPct}%</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">${uniqueTwoBytePrefixes} of 65,536 possible</div>
</div>
<div class="analytics-stat-card" style="flex:1;min-width:110px;border-color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--border)'}">
<div class="analytics-stat-label">Prefix collisions</div>
<div class="analytics-stat-value" style="color:${twoCollisions > 0 ? 'var(--status-red)' : 'var(--status-green)'}">${twoCollisions}</div>
</div>
</div>`;
html += `<div style="display:flex;gap:16px;flex-wrap:wrap"><div class="hash-matrix-scroll"><table class="hash-matrix-table" style="border-collapse:collapse;font-size:12px;font-family:monospace">`;
html += `<tr><td style="width:${headerSize}px"></td>`;
for (const n of nibbles) html += `<td style="width:${cellSize}px;text-align:center;padding:2px 0;font-weight:bold;color:var(--text-muted)">${n}</td>`;
html += '</tr>';
for (let hi = 0; hi < 16; hi++) {
html += `<tr><td style="text-align:right;padding-right:4px;font-weight:bold;color:var(--text-muted)">${nibbles[hi]}</td>`;
for (let lo = 0; lo < 16; lo++) {
const hex = nibbles[hi] + nibbles[lo];
const info = firstByteInfo[hex] || { groupNodes: [], maxCollision: 0, collisionCount: 0 };
const nodeCount = info.groupNodes.length;
const maxCol = info.maxCollision;
// Classify worst overlap in group: confirmed collision (2+ repeaters) or possible (null-role involved)
const overlapping = Object.values(info.twoByteMap || {}).filter(v => v.length > 1);
const hasConfirmed = overlapping.some(ns => ns.filter(n => n.role === 'repeater').length >= 2);
const hasPossible = !hasConfirmed && overlapping.some(ns => ns.length >= 2);
let cellClass2, bgStyle2;
if (nodeCount === 0) { cellClass2 = 'hash-cell-empty'; bgStyle2 = ''; }
else if (maxCol === 0) { cellClass2 = 'hash-cell-taken'; bgStyle2 = ''; }
else if (hasPossible) { cellClass2 = 'hash-cell-possible'; bgStyle2 = ''; }
else { const t = Math.min((maxCol - 2) / 4, 1); bgStyle2 = `background:rgb(${Math.round(220+35*t)},${Math.round(120*(1-t))},30);`; cellClass2 = 'hash-cell-collision'; }
const nodeLabel2 = m => esc(m.name||m.public_key.slice(0,8)) + (!m.role ? ' (?)' : '');
const tip2 = nodeCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">No nodes in this group</div>`
: info.collisionCount === 0
? `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${nodeCount} node${nodeCount>1?'s':''} — no 2-byte collisions</div>`
: `<div class="hash-matrix-tooltip-hex">0x${hex}__</div><div class="hash-matrix-tooltip-status">${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}</div><div class="hash-matrix-tooltip-nodes">${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`<div style="font-size:11px;padding:1px 0"><span style="color:${hasConfirmed?'var(--status-red)':'var(--status-yellow)'};font-family:var(--mono);font-weight:700">${p}</span> — ${ns.map(nodeLabel2).join(', ')}</div>`).join('')}</div>`;
html += `<td class="hash-cell ${cellClass2}${nodeCount ? ' hash-active' : ''}" data-hex="${hex}" data-tip="${tip2.replace(/"/g,'&quot;')}" style="width:${cellSize}px;height:${cellSize}px;text-align:center;${bgStyle2}border:1px solid var(--border);cursor:${nodeCount ? 'pointer' : 'default'};font-size:11px;font-weight:${maxCol > 0 ? '700' : '400'}">${hex}</td>`;
}
html += '</tr>';
}
html += '</table></div>';
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:420px;font-size:0.85em"></div></div>
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span><span class="legend-swatch hash-cell-empty" style="border:1px solid var(--border)"></span> No nodes in group</span>
<span><span class="legend-swatch hash-cell-taken"></span> Nodes present, no collision</span>
<span><span class="legend-swatch hash-cell-possible"></span> Possible conflict</span>
<span><span class="legend-swatch hash-cell-collision" style="background:rgb(220,80,30)"></span> Collision</span>
</div>`;
el.innerHTML = html;
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
const hex = td.dataset.hex.toUpperCase();
const info = firstByteInfo[hex];
const detail = document.getElementById('hashDetail');
if (!info || !info.groupNodes.length) { detail.innerHTML = ''; return; }
let dhtml = `<strong class="mono" style="font-size:1.1em">0x${hex}__</strong> — ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`;
if (info.collisionCount === 0) {
dhtml += `<div class="text-muted" style="margin-top:6px;font-size:0.85em">✅ No 2-byte collisions in this group</div>`;
dhtml += `<div style="margin-top:8px">${info.groupNodes.map(m => {
const prefix = m.public_key.slice(0,4).toUpperCase();
return `<div style="padding:2px 0"><code class="mono" style="font-size:0.85em">${prefix}</code> <a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a></div>`;
}).join('')}</div>`;
} else {
dhtml += `<div style="margin-top:8px">`;
for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) {
const isCollision = nodes.length > 1;
dhtml += `<div style="margin-bottom:6px;padding:4px 6px;border-radius:4px;background:${isCollision ? 'rgba(220,50,30,0.1)' : 'transparent'};border:1px solid ${isCollision ? 'rgba(220,50,30,0.3)' : 'transparent'}">`;
dhtml += `<code class="mono" style="font-size:0.9em;font-weight:${isCollision?'700':'400'}">${twoHex}</code>${isCollision ? ' <span style="color:#dc2626;font-size:0.75em;font-weight:700">COLLISION</span>' : ''} `;
dhtml += nodes.map(m => `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link" style="font-size:0.85em">${esc(m.name || m.public_key.slice(0,12))}</a>`).join(', ');
dhtml += `</div>`;
}
dhtml += '</div>';
}
detail.innerHTML = dhtml;
el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected'));
td.classList.add('hash-selected');
});
});
initMatrixTooltip(el);
}
}
async function renderCollisions(topHops, allNodes) {
async function renderCollisions(topHops, allNodes, bytes) {
bytes = bytes || 1;
const el = document.getElementById('collisionList');
const oneByteHops = topHops.filter(h => h.size === 1);
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
const hopsForSize = topHops.filter(h => h.size === bytes);
// For 2-byte and 3-byte, scan nodes directly — topHops only reliably covers 1-byte path hops
const hopsToCheck = bytes === 1 ? hopsForSize : buildCollisionHops(allNodes, bytes);
if (!hopsToCheck.length && bytes === 1) {
el.innerHTML = `<div class="text-muted" style="padding:8px">No 1-byte hops observed in recent packets.</div>`;
return;
}
try {
const nodes = allNodes;
const collisions = [];
for (const hop of oneByteHops) {
for (const hop of hopsToCheck) {
const prefix = hop.hex.toLowerCase();
const matches = nodes.filter(n => n.public_key.toLowerCase().startsWith(prefix));
if (matches.length > 1) {
@@ -1145,14 +1436,27 @@
collisions.push({ hop: hop.hex, count: hop.count, matches, maxDistKm, classification, withCoords: withCoords.length });
}
}
if (!collisions.length) { el.innerHTML = '<div class="text-muted" style="padding:8px">No collisions detected</div>'; return; }
if (!collisions.length) {
const cleanMsg = bytes === 3
? '✅ No 3-byte prefix collisions detected — all nodes have unique 3-byte prefixes.'
: `✅ No ${bytes}-byte collisions detected`;
el.innerHTML = `<div class="text-muted" style="padding:8px">${cleanMsg}</div>`;
return;
}
// Sort: local first (most likely to collide), then regional, distant, incomplete
const classOrder = { local: 0, regional: 1, distant: 2, incomplete: 3, unknown: 4 };
collisions.sort((a, b) => classOrder[a.classification] - classOrder[b.classification] || b.count - a.count);
const showAppearances = bytes < 3;
el.innerHTML = `<table class="analytics-table">
<thead><tr><th scope="col">Hop</th><th scope="col">Appearances</th><th scope="col">Max Distance</th><th scope="col">Assessment</th><th scope="col">Colliding Nodes</th></tr></thead>
<thead><tr>
<th scope="col">Prefix</th>
${showAppearances ? '<th scope="col">Appearances</th>' : ''}
<th scope="col">Max Distance</th>
<th scope="col">Assessment</th>
<th scope="col">Colliding Nodes</th>
</tr></thead>
<tbody>${collisions.map(c => {
let badge, tooltip;
if (c.classification === 'local') {
@@ -1171,12 +1475,12 @@
const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : '<span class="text-muted">—</span>';
return `<tr>
<td class="mono">${c.hop}</td>
<td>${c.count.toLocaleString()}</td>
${showAppearances ? `<td>${c.count.toLocaleString()}</td>` : ''}
<td>${distStr}</td>
<td title="${tooltip}">${badge}</td>
<td>${c.matches.map(m => {
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0))
? ` <span class="text-muted" style="font-size:0.75em">(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})</span>`
: ' <span class="text-muted" style="font-size:0.75em">(no coords)</span>';
return `<a href="#/nodes/${encodeURIComponent(m.public_key)}" class="analytics-link">${esc(m.name || m.public_key.slice(0,12))}</a>${loc}`;
}).join('<br>')}</td>
@@ -1638,6 +1942,9 @@ function destroy() { _analyticsData = {}; _channelData = null; }
window._analyticsSaveChannelSort = saveChannelSort;
window._analyticsChannelTbodyHtml = channelTbodyHtml;
window._analyticsChannelTheadHtml = channelTheadHtml;
window._analyticsBuildOneBytePrefixMap = buildOneBytePrefixMap;
window._analyticsBuildTwoBytePrefixInfo = buildTwoBytePrefixInfo;
window._analyticsBuildCollisionHops = buildCollisionHops;
}
registerPage('analytics', { init, destroy });
+2 -2
View File
@@ -22,7 +22,7 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774786038">
<link rel="stylesheet" href="style.css?v=1774786041">
<link rel="stylesheet" href="home.css?v=1774786038">
<link rel="stylesheet" href="live.css?v=1774786038">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -94,7 +94,7 @@
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786041" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
+16
View File
@@ -1096,6 +1096,22 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-bar-fill { height: 100%; border-radius: 4px; transition: width .3s; }
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
.hash-cell-empty { background: var(--card-bg); color: var(--text-muted); }
.hash-cell-taken { background: var(--status-green); color: #fff; }
.hash-cell-possible { background: var(--status-yellow); color: #fff; }
.hash-cell-collision { color: #fff; }
.hash-matrix-tooltip {
position: fixed; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); padding: 8px 12px;
font-size: 12px; min-width: 160px; max-width: 260px; pointer-events: none;
}
.hash-matrix-tooltip-hex { font-family: var(--mono); font-size: 13px; font-weight: 700; margin-bottom: 4px; color: var(--accent); }
.hash-matrix-tooltip-status { color: var(--text-muted); font-size: 11px; }
.hash-matrix-tooltip-nodes { margin-top: 6px; display: flex; flex-direction: column; gap: 2px; }
.hash-byte-selector { display: flex; gap: 4px; }
.hash-byte-btn { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; }
.hash-byte-btn:hover { background: var(--border); color: var(--text); }
.hash-byte-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
+148
View File
@@ -1602,6 +1602,154 @@ console.log('\n=== analytics.js: sortChannels ===');
});
}
// === analytics.js: hash prefix helpers ===
console.log('\n=== analytics.js: hash prefix helpers ===');
{
const ctx = (() => {
const c = makeSandbox();
c.getComputedStyle = () => ({ getPropertyValue: () => '' });
c.registerPage = () => {};
c.api = () => Promise.resolve({});
c.timeAgo = () => '—';
c.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' };
c.onWS = () => {};
c.offWS = () => {};
c.connectWS = () => {};
c.invalidateApiCache = () => {};
c.makeColumnsResizable = () => {};
c.initTabBar = () => {};
c.IATA_COORDS_GEO = {};
loadInCtx(c, 'public/roles.js');
loadInCtx(c, 'public/app.js');
try { loadInCtx(c, 'public/analytics.js'); } catch (e) {
for (const k of Object.keys(c.window)) c[k] = c.window[k];
}
return c;
})();
const buildOne = ctx.window._analyticsBuildOneBytePrefixMap;
const buildTwo = ctx.window._analyticsBuildTwoBytePrefixInfo;
const buildHops = ctx.window._analyticsBuildCollisionHops;
const node = (pk, extra) => ({ public_key: pk, name: pk.slice(0, 4), ...(extra || {}) });
test('buildOneBytePrefixMap exports exist', () => assert.ok(buildOne, 'must be exported'));
test('buildTwoBytePrefixInfo exports exist', () => assert.ok(buildTwo, 'must be exported'));
test('buildCollisionHops exports exist', () => assert.ok(buildHops, 'must be exported'));
// --- 1-byte prefix map ---
test('1-byte map has 256 keys', () => {
const m = buildOne([]);
assert.strictEqual(Object.keys(m).length, 256);
});
test('1-byte map places node in correct bucket', () => {
const n = node('AABBCC');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
assert.strictEqual(m['AA'][0].public_key, 'AABBCC');
assert.strictEqual(m['BB'].length, 0);
});
test('1-byte map groups two nodes with same prefix', () => {
const a = node('AA1111'), b = node('AA2222');
const m = buildOne([a, b]);
assert.strictEqual(m['AA'].length, 2);
});
test('1-byte map is case-insensitive for node keys', () => {
const n = node('aabbcc');
const m = buildOne([n]);
assert.strictEqual(m['AA'].length, 1);
});
test('1-byte map: empty input yields all empty buckets', () => {
const m = buildOne([]);
assert.ok(Object.values(m).every(v => v.length === 0));
});
// --- 2-byte prefix info ---
test('2-byte info has 256 first-byte keys', () => {
const info = buildTwo([]);
assert.strictEqual(Object.keys(info).length, 256);
});
test('2-byte info: no nodes → zero collisions', () => {
const info = buildTwo([]);
assert.ok(Object.values(info).every(e => e.collisionCount === 0));
});
test('2-byte info: node placed in correct first-byte group', () => {
const n = node('AABB1122');
const info = buildTwo([n]);
assert.strictEqual(info['AA'].groupNodes.length, 1);
assert.strictEqual(info['BB'].groupNodes.length, 0);
});
test('2-byte info: same 2-byte prefix = collision', () => {
const a = node('AABB0001'), b = node('AABB0002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 1);
assert.strictEqual(info['AA'].maxCollision, 2);
});
test('2-byte info: different 2-byte prefixes in same group = no collision', () => {
const a = node('AA110001'), b = node('AA220002');
const info = buildTwo([a, b]);
assert.strictEqual(info['AA'].collisionCount, 0);
assert.strictEqual(info['AA'].maxCollision, 0);
});
test('2-byte info: twoByteMap built correctly', () => {
const a = node('AABB0001'), b = node('AABB0002'), c = node('AACC0003');
const info = buildTwo([a, b, c]);
assert.strictEqual(Object.keys(info['AA'].twoByteMap).length, 2);
assert.strictEqual(info['AA'].twoByteMap['AABB'].length, 2);
assert.strictEqual(info['AA'].twoByteMap['AACC'].length, 1);
});
// --- 3-byte stat summary (via buildCollisionHops) ---
test('buildCollisionHops: no collisions returns empty array', () => {
const nodes = [node('AA000001'), node('BB000002'), node('CC000003')];
assert.deepStrictEqual(buildHops(nodes, 1), []);
});
test('buildCollisionHops: detects 1-byte collision', () => {
const nodes = [node('AA000001'), node('AA000002')];
const hops = buildHops(nodes, 1);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AA');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 2-byte collision', () => {
const nodes = [node('AABB0001'), node('AABB0002'), node('AACC0003')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABB');
assert.strictEqual(hops[0].count, 2);
});
test('buildCollisionHops: detects 3-byte collision', () => {
const nodes = [node('AABBCC0001'), node('AABBCC0002')];
const hops = buildHops(nodes, 3);
assert.strictEqual(hops.length, 1);
assert.strictEqual(hops[0].hex, 'AABBCC');
});
test('buildCollisionHops: size field set correctly', () => {
const nodes = [node('AABB0001'), node('AABB0002')];
const hops = buildHops(nodes, 2);
assert.strictEqual(hops[0].size, 2);
});
test('buildCollisionHops: empty input returns empty array', () => {
assert.deepStrictEqual(buildHops([], 1), []);
assert.deepStrictEqual(buildHops([], 2), []);
assert.deepStrictEqual(buildHops([], 3), []);
});
}
// ===== SUMMARY =====
console.log(`\n${'═'.repeat(40)}`);
console.log(` Frontend helpers: ${passed} passed, ${failed} failed`);