Geographic hop disambiguation for 1-byte path prefixes

When multiple nodes match a 1-byte hop prefix, use geographic context
(observer's typical nodes + other resolved hops) to pick the closest
match. Shows ⚠ indicator on ambiguous hops with tooltip listing all
candidates. Hover to see which nodes collide on that prefix.
This commit is contained in:
you committed 2026-03-18 23:41:26 +00:00
1 parent 72f540db86
commit 17524fa3db
3 files changed
+92 -13

No files matched your search

+11 -6
View File
@@ -64,11 +64,15 @@
}
function renderHop(h) {
const name = hopNameCache[h];
const entry = hopNameCache[h];
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
const pubkey = entry?.pubkey || h;
const ambiguous = entry?.ambiguous || false;
const display = name ? escapeHtml(name) : h;
const pubkey = name ? Object.entries(hopNameCache).find(([k,v]) => v === name)?.[0] || h : h;
// Try to find full pubkey from nodeData
return `<a class="hop hop-link ${name ? 'hop-named' : ''}" href="#/nodes/${encodeURIComponent(h)}" title="${h}" onclick="event.stopPropagation()">${display}</a>`;
const title = ambiguous
? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
: h;
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" onclick="event.stopPropagation()">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
}
function renderPath(hops) {
@@ -555,8 +559,9 @@
const pathByte = parseInt(buf.slice(2, 4), 16);
const hashSize = (pathByte >> 6) + 1;
for (let i = 0; i < pathHops.length; i++) {
const hopName = hopNameCache[pathHops[i]];
const label = hopName ? `Hop ${i} — ${escapeHtml(hopName)}` : `Hop ${i}`;
const hopEntry = hopNameCache[pathHops[i]];
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
const label = hopName ? `Hop ${i} — ${escapeHtml(hopName)}${hopEntry?.ambiguous ? ' ⚠' : ''}` : `Hop ${i}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], hopName ? hopName : '');
}
off += hashSize * pathHops.length;
+4
View File
@@ -1142,3 +1142,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
font-weight: 600;
font-size: 0.75rem;
}
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
+77 -7
View File
@@ -566,11 +566,23 @@ app.get('/api/analytics/rf', (req, res) => {
// --- Topology Analytics ---
app.get('/api/analytics/topology', (req, res) => {
const packets = db.db.prepare(`SELECT path_json, snr, decoded_json, observer_id FROM packets WHERE path_json IS NOT NULL AND path_json != '[]'`).all();
const allNodes = db.db.prepare('SELECT public_key, name FROM nodes WHERE name IS NOT NULL').all();
const resolveHop = hop => {
const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all();
const resolveHop = (hop, contextPositions) => {
const h = hop.toLowerCase();
const m = allNodes.find(n => n.public_key.toLowerCase().startsWith(h));
return m ? { name: m.name, pubkey: m.public_key } : null;
const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(h));
if (candidates.length === 0) return null;
if (candidates.length === 1) return { name: candidates[0].name, pubkey: candidates[0].public_key };
// Disambiguate by proximity to context positions
if (contextPositions && contextPositions.length > 0) {
const cLat = contextPositions.reduce((s, p) => s + p.lat, 0) / contextPositions.length;
const cLon = contextPositions.reduce((s, p) => s + p.lon, 0) / contextPositions.length;
const withLoc = candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (withLoc.length) {
withLoc.sort((a, b) => Math.hypot(a.lat - cLat, a.lon - cLon) - Math.hypot(b.lat - cLat, b.lon - cLon));
return { name: withLoc[0].name, pubkey: withLoc[0].public_key };
}
}
return { name: candidates[0].name, pubkey: candidates[0].public_key };
};
// Hop distribution
@@ -866,14 +878,72 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
// Resolve path hop hex prefixes to node names
app.get('/api/resolve-hops', (req, res) => {
const hops = (req.query.hops || '').split(',').filter(Boolean);
const observerId = req.query.observer || null;
if (!hops.length) return res.json({ resolved: {} });
const allNodes = db.db.prepare('SELECT public_key, name FROM nodes WHERE name IS NOT NULL').all();
const allNodes = db.db.prepare('SELECT public_key, name, lat, lon FROM nodes WHERE name IS NOT NULL').all();
// Build observer geographic center from nodes it typically hears
let observerLat = null, observerLon = null;
if (observerId) {
const obsNodes = db.db.prepare(`
SELECT DISTINCT n.lat, n.lon FROM packets p
JOIN nodes n ON p.decoded_json LIKE '%' || n.public_key || '%'
WHERE p.observer_id = ? AND n.lat IS NOT NULL AND n.lat != 0 AND n.lon != 0
LIMIT 50
`).all(observerId);
if (obsNodes.length) {
observerLat = obsNodes.reduce((s, n) => s + n.lat, 0) / obsNodes.length;
observerLon = obsNodes.reduce((s, n) => s + n.lon, 0) / obsNodes.length;
}
}
const resolved = {};
// First pass: find all candidates for each hop
for (const hop of hops) {
const hopLower = hop.toLowerCase();
const match = allNodes.find(n => n.public_key.toLowerCase().startsWith(hopLower));
if (match) resolved[hop] = match.name;
const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (candidates.length === 0) {
resolved[hop] = { name: null, candidates: [] };
} else if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
}
}
// Second pass: disambiguate using geographic context
// Collect known positions from unambiguous hops
const knownPositions = [];
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
const node = allNodes.find(n => n.public_key === r.pubkey);
if (node && node.lat && node.lon && !(node.lat === 0 && node.lon === 0)) {
knownPositions.push({ lat: node.lat, lon: node.lon });
}
}
}
if (observerLat != null) knownPositions.push({ lat: observerLat, lon: observerLon });
if (knownPositions.length > 0) {
const centerLat = knownPositions.reduce((s, p) => s + p.lat, 0) / knownPositions.length;
const centerLon = knownPositions.reduce((s, p) => s + p.lon, 0) / knownPositions.length;
const dist = (lat, lon) => Math.sqrt((lat - centerLat) ** 2 + (lon - centerLon) ** 2);
for (const hop of hops) {
const r = resolved[hop];
if (r && r.ambiguous) {
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (withLoc.length > 0) {
withLoc.sort((a, b) => dist(a.lat, a.lon) - dist(b.lat, b.lon));
r.name = withLoc[0].name;
r.pubkey = withLoc[0].pubkey;
}
}
}
}
res.json({ resolved });
});