diff --git a/public/analytics.js b/public/analytics.js index 0f55c697..a02ec40b 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -745,12 +745,19 @@ return svg; } - function renderHashMatrix(topHops) { + async function renderHashMatrix(topHops) { const el = document.getElementById('hashMatrix'); const oneByteHops = topHops.filter(h => h.size === 1); if (!oneByteHops.length) { el.innerHTML = '
No 1-byte hops
'; return; } - // Build 16x16 grid: row = high nibble, col = low nibble + // Fetch all nodes for lookup + let allNodes = []; + try { + const nd = await api('/nodes?limit=2000'); + allNodes = nd.nodes || []; + } catch {} + + // Build 16x16 grid const grid = Array.from({ length: 16 }, () => Array(16).fill(0)); let maxCount = 0; for (const hop of oneByteHops) { @@ -766,8 +773,7 @@ const cellSize = 36; const headerSize = 24; - let html = `
`; - // Header row + let html = `
`; html += ``; for (const n of nibbles) { html += ``; @@ -789,12 +795,38 @@ bg = `rgb(${r},${g},${b})`; color = intensity > 0.5 ? '#fff' : 'var(--text)'; } - html += ``; + const nodeCount = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hex.toLowerCase())).length; + html += ``; } html += ''; } html += '
${n}${count || ''}${count || ''}
'; + html += '
'; el.innerHTML = html; + + // Click handler for cells + el.querySelectorAll('.hash-active').forEach(td => { + td.addEventListener('click', () => { + const hex = td.dataset.hex.toLowerCase(); + const matches = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hex)); + const detail = document.getElementById('hashDetail'); + if (!matches.length) { + detail.innerHTML = `0x${hex.toUpperCase()}
No known nodes`; + return; + } + detail.innerHTML = `0x${hex.toUpperCase()} — ${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('')}
`; + // Highlight selected cell + el.querySelectorAll('.hash-selected').forEach(c => c.classList.remove('hash-selected')); + td.classList.add('hash-selected'); + }); + }); } async function renderCollisions(topHops) { diff --git a/public/style.css b/public/style.css index 6c73528a..d75c5958 100644 --- a/public/style.css +++ b/public/style.css @@ -1011,6 +1011,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .hash-bar-label { min-width: 160px; font-size: 13px; } .hash-bar-track { flex: 1; height: 24px; background: var(--border); border-radius: 4px; overflow: hidden; } .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-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; } .badge-hash-1 { background: #ef444420; color: #ef4444; } .badge-hash-2 { background: #22c55e20; color: #22c55e; }