diff --git a/public/packets.js b/public/packets.js
index badec86b..f11d82f1 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -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 `${display}`;
+ const title = ambiguous
+ ? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
+ : h;
+ return `${display}${ambiguous ? '⚠' : ''}`;
}
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;
diff --git a/public/style.css b/public/style.css
index 04a6f1f4..e60389bb 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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; }
diff --git a/server.js b/server.js
index a725c243..b6721229 100644
--- a/server.js
+++ b/server.js
@@ -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 });
});