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 += `${n} | `;
@@ -789,12 +795,38 @@
bg = `rgb(${r},${g},${b})`;
color = intensity > 0.5 ? '#fff' : 'var(--text)';
}
- html += `${count || ''} | `;
+ const nodeCount = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hex.toLowerCase())).length;
+ html += `${count || ''} | `;
}
html += '
';
}
html += '
';
+ 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 `
`;
+ }).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; }