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 @@
Click a cell to see which nodes share that prefix. Green = available, yellow = taken, red = collision.
+Click a cell to see which nodes share that prefix.
+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 = `| `; - for (const n of nibbles) { - html += ` | ${n} | `; - } - html += '||
| ${nibbles[hi]} | `; - 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 += `${cellText} | `; - } + let html = `
| `; + for (const n of nibbles) html += ` | ${n} | `; html += '
| `; + for (const n of nibbles) html += ` | ${n} | `; + html += '
| ${nibbles[hi]} | `; + 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 + ? `${hex} | `; + } + html += '
${twoHex}${isCollision ? ' COLLISION' : ''} `;
+ dhtml += nodes.map(m => `${esc(m.name || m.public_key.slice(0,12))}`).join(', ');
+ dhtml += `| Hop | Appearances | Max Distance | Assessment | Colliding Nodes | |
|---|---|---|---|---|---|
| Prefix | + ${showAppearances ? 'Appearances | ' : ''} +Max Distance | +Assessment | +Colliding Nodes | +|
| ${c.hop} | -${c.count.toLocaleString()} | + ${showAppearances ? `${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(' ')} |
@@ -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`);