From faca80e626ba1a5572bbb8a6275f3d6464247fbc Mon Sep 17 00:00:00 2001 From: VE7KOD Date: Mon, 30 Mar 2026 09:31:35 -0700 Subject: [PATCH] feat: add multi-byte hash usage matrix with stats and improved tooltips (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Co-authored-by: Claude Sonnet 4.6 --- public/analytics.js | 469 ++++++++++++++++++++++++++++++++------- public/index.html | 4 +- public/style.css | 16 ++ test-frontend-helpers.js | 148 ++++++++++++ 4 files changed, 554 insertions(+), 83 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index a4ceed7e..28f98603 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -960,13 +960,23 @@
-

πŸ”’ 1-Byte Hash Usage Matrix

↑ top
-

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

+
+

πŸ”’ Hash Usage Matrix

+ ↑ top +
+
+
+ + + +
+

Click a cell to see which nodes share that prefix.

+
-

πŸ’₯ 1-Byte Collision Risk

↑ top
+

πŸ’₯ Collision Risk

↑ top
Loading…
`; @@ -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 . + // 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 = ` +
+
+
Nodes tracked
+
${total.toLocaleString()}
+
+
+
Using 3-byte ID
+
${threeByteNodes.toLocaleString()}
+
+
+
Prefix space used
+
${pct}%
+
of 16.7M possible
+
+
+
Prefix collisions
+
${collisions}
+
+
+

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; - let html = `
`; - html += ``; - for (const n of nibbles) { - html += ``; - } - html += ''; + 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 += ``; - for (let lo = 0; lo < 16; lo++) { - const hex = nibbles[hi] + nibbles[lo]; - const nodes = prefixNodes[hex] || []; - const count = nodes.length; - let bg, color; - if (count === 0) { - bg = 'var(--card-bg)'; color = 'var(--text-muted)'; // empty — subtle - } else if (count === 1) { - bg = '#dcfce7'; color = '#166534'; // light green — taken, no collision - } else { - // 2+ nodes: orange→red - const t = Math.min((count - 2) / 4, 1); - const r = Math.round(220 + 35 * t); - const g = Math.round(120 * (1 - t)); - bg = `rgb(${r},${g},30)`; color = '#fff'; - } - const status = count === 0 ? 'available' : count === 1 ? `1 node: ${nodes[0].name || nodes[0].public_key.slice(0,12)}` : `${count} nodes — COLLISION`; - const cellText = count === 0 ? `${hex}` : count >= 2 ? `${count >= 3 ? '3+' : count}` : String(count); - html += ``; - } + let html = `
+
+
Nodes tracked
+
${totalNodes.length.toLocaleString()}
+
+
+
Using 1-byte ID
+
${oneByteCount.toLocaleString()}
+
+
+
Prefix space used
+
${onePct}%
+
of 256 possible
+
+
+
Prefix collisions
+
${oneCollisions}
+
+
`; + html += `
${n}
${nibbles[hi]}${cellText}
`; + html += ``; + for (const n of nibbles) html += ``; html += ''; - } - html += '
${n}
'; - html += `
-
- 0 β€” Available - 1 β€” One node - 2 β€” Two nodes (collision) - 3+ β€” Three+ nodes (collision) -
`; - el.innerHTML = html; - - // Click handler for cells - el.querySelectorAll('.hash-active').forEach(td => { - td.addEventListener('click', () => { - const hex = td.dataset.hex.toUpperCase(); - const matches = prefixNodes[hex] || []; - const detail = document.getElementById('hashDetail'); - if (!matches.length) { - detail.innerHTML = `0x${hex}
No known nodes`; - return; + for (let hi = 0; hi < 16; hi++) { + html += `${nibbles[hi]}`; + 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 => `
${esc(m.name||m.public_key.slice(0,12))}${!m.role ? ' (unknown role)' : ''}
`; + const tip1 = count === 0 + ? `
0x${hex}
Available
` + : count === 1 + ? `
0x${hex}
One node β€” no collision
${nodeLabel(nodes[0])}
` + : isPossible + ? `
0x${hex}
${count} nodes β€” POSSIBLE CONFLICT
${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`
+${nodes.length-5} more
`:''}
` + : `
0x${hex}
${count} nodes β€” COLLISION
${nodes.slice(0,5).map(nodeLabel).join('')}${nodes.length>5?`
+${nodes.length-5} more
`:''}
`; + html += `${hex}`; } - detail.innerHTML = `0x${hex} β€” ${matches.length} node${matches.length !== 1 ? 's' : ''}` + - `
${matches.map(m => { - const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) - ? `(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` - : '(no coords)'; - const role = m.role ? `${esc(m.role)} ` : ''; - return `
${role}${esc(m.name || m.public_key.slice(0,12))} ${coords}
`; - }).join('')}
`; - el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); - td.classList.add('hash-selected'); + html += ''; + } + html += ''; + html += `
+
+ Available + One node + Possible conflict + Collision +
`; + 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 = `0x${hex}
No known nodes`; return; } + detail.innerHTML = `0x${hex} β€” ${matches.length} node${matches.length !== 1 ? 's' : ''}` + + `
${matches.map(m => { + const coords = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) ? `(${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` : '(no coords)'; + const role = m.role ? `${esc(m.role)} ` : ''; + return `
${role}${esc(m.name || m.public_key.slice(0,12))} ${coords}
`; + }).join('')}
`; + el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); + td.classList.add('hash-selected'); + }); }); - }); + + } else if (bytes === 2) { + // 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 = `
+
+
Nodes tracked
+
${totalNodes.length.toLocaleString()}
+
+
+
Using 2-byte ID
+
${twoByteCount.toLocaleString()}
+
+
+
Prefix space used
+
${twoPct}%
+
${uniqueTwoBytePrefixes} of 65,536 possible
+
+
+
Prefix collisions
+
${twoCollisions}
+
+
`; + html += `
`; + html += ``; + for (const n of nibbles) html += ``; + html += ''; + for (let hi = 0; hi < 16; hi++) { + html += ``; + for (let lo = 0; lo < 16; lo++) { + const hex = nibbles[hi] + nibbles[lo]; + const 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 + ? `
0x${hex}__
No nodes in this group
` + : info.collisionCount === 0 + ? `
0x${hex}__
${nodeCount} node${nodeCount>1?'s':''} β€” no 2-byte collisions
` + : `
0x${hex}__
${hasConfirmed ? info.collisionCount + ' collision' + (info.collisionCount>1?'s':'') : 'Possible conflict'}
${Object.entries(info.twoByteMap).filter(([,v])=>v.length>1).slice(0,4).map(([p,ns])=>`
${p} β€” ${ns.map(nodeLabel2).join(', ')}
`).join('')}
`; + html += ``; + } + html += ''; + } + html += '
${n}
${nibbles[hi]}${hex}
'; + html += `
+
+ No nodes in group + Nodes present, no collision + Possible conflict + Collision +
`; + 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 = `0x${hex}__ β€” ${info.groupNodes.length} node${info.groupNodes.length !== 1 ? 's' : ''} in group`; + if (info.collisionCount === 0) { + dhtml += `
βœ… No 2-byte collisions in this group
`; + dhtml += `
${info.groupNodes.map(m => { + const prefix = m.public_key.slice(0,4).toUpperCase(); + return `
${prefix} ${esc(m.name || m.public_key.slice(0,12))}
`; + }).join('')}
`; + } else { + dhtml += `
`; + for (const [twoHex, nodes] of Object.entries(info.twoByteMap).sort()) { + const isCollision = nodes.length > 1; + dhtml += `
`; + dhtml += `${twoHex}${isCollision ? ' COLLISION' : ''} `; + dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', '); + dhtml += `
`; + } + dhtml += '
'; + } + detail.innerHTML = dhtml; + el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); + td.classList.add('hash-selected'); + }); + }); + + initMatrixTooltip(el); + } } - 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 = '
No 1-byte hops
'; 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 = `
No 1-byte hops observed in recent packets.
`; + 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 = '
No collisions detected
'; 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 = `
${cleanMsg}
`; + 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 = ` - + + + ${showAppearances ? '' : ''} + + + + ${collisions.map(c => { let badge, tooltip; if (c.classification === 'local') { @@ -1171,12 +1475,12 @@ const distStr = c.withCoords >= 2 ? `${Math.round(c.maxDistKm)} km` : 'β€”'; return ` - + ${showAppearances ? `` : ''} @@ -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 }); diff --git a/public/index.html b/public/index.html index 6a1a01ee..729d1769 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@ - + - + diff --git a/public/style.css b/public/style.css index e30c5a76..c536024d 100644 --- a/public/style.css +++ b/public/style.css @@ -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); } diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index e1ec9a31..d14436e3 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -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`);
HopAppearancesMax DistanceAssessmentColliding Nodes
PrefixAppearancesMax DistanceAssessmentColliding Nodes
${c.hop}${c.count.toLocaleString()}${c.count.toLocaleString()}${distStr} ${badge} ${c.matches.map(m => { - const loc = (m.lat && m.lon && !(m.lat === 0 && m.lon === 0)) - ? ` (${m.lat.toFixed(2)}, ${m.lon.toFixed(2)})` + 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('
')}