Fix #117: Regional filtering for repeater ID resolution

1-byte (and 2-byte) hop IDs match many nodes globally. Previously
resolve-hops picked candidates from anywhere, causing cross-regional
false paths (e.g. Eugene packet showing Vancouver repeaters).

Fix: Use observer IATA to determine packet region. Filter candidates
to nodes seen by observers in the same IATA region via the existing
_advertByObserver index. Fall back to global only if zero regional
candidates exist (flagged as globalFallback).

API changes to /api/resolve-hops response:
- conflicts[]: all candidates with regional flag per hop
- totalGlobal/totalRegional: candidate counts
- globalFallback: true when no regional candidates found
- region: packet IATA region in top-level response

UI changes:
- Conflict count badge (⚠3) instead of bare ⚠
- Tooltip shows regional vs global candidates
- Unreliable hops shown with strikethrough + opacity
- Global fallback hops shown with red dashed underline
This commit is contained in:
you
2026-03-22 21:38:24 +00:00
parent f4e6c34ad5
commit 2fc07ef697
4 changed files with 93 additions and 33 deletions
+2 -2
View File
@@ -22,7 +22,7 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774214493">
<link rel="stylesheet" href="style.css?v=1774215504">
<link rel="stylesheet" href="home.css">
<link rel="stylesheet" href="live.css?v=1774058575">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -85,7 +85,7 @@
<script src="hop-resolver.js?v=1774126708"></script>
<script src="app.js?v=1774126708"></script>
<script src="home.js?v=1774042199"></script>
<script src="packets.js?v=1774205722"></script>
<script src="packets.js?v=1774215504"></script>
<script src="map.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
+27 -5
View File
@@ -129,11 +129,29 @@
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
const pubkey = entry?.pubkey || h;
const ambiguous = entry?.ambiguous || false;
const conflicts = entry?.conflicts || [];
const regionalCount = conflicts.filter(c => c.regional).length;
const totalGlobal = entry?.totalGlobal || conflicts.length;
const globalFallback = entry?.globalFallback || false;
const unreliable = entry?.unreliable || false;
const display = name ? escapeHtml(name) : h;
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}" data-hop-link="true">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
const hopBytes = Math.ceil(h.length / 2);
let title = h;
if (ambiguous && conflicts.length > 0) {
const regionLabel = regionalCount > 0 ? `${regionalCount} regional` : `${totalGlobal} global (no regional match)`;
title = `${h} (${hopBytes}B) — ⚠ ${regionLabel}: ${conflicts.map(c => c.name + (c.regional ? '' : ' ⚑')).join(', ')}`;
}
if (unreliable) title += ' — ✗ unreliable (too far from neighbors)';
if (globalFallback) title += ' — ⚑ no regional candidates, using global fallback';
const warnBadge = ambiguous
? `<span class="hop-warn" title="${conflicts.length} conflicts">${conflicts.length > 1 ? '⚠' + conflicts.length : '⚠'}</span>`
: '';
const unreliableCls = unreliable ? ' hop-unreliable' : '';
const fallbackCls = globalFallback ? ' hop-global-fallback' : '';
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}${unreliableCls}${fallbackCls}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" data-hop-link="true">${display}${warnBadge}</a>`;
}
function renderPath(hops) {
@@ -1305,8 +1323,12 @@
const hopEntry = hopNameCache[pathHops[i]];
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
const hopPubkey = hopEntry?.pubkey || pathHops[i];
const conflicts = hopEntry?.conflicts || [];
const conflictInfo = conflicts.length > 1
? ` <span class="hop-warn" title="${conflicts.length} candidates: ${conflicts.map(c => c.name + (c.regional ? '' : ' (global)')).join(', ')}">⚠${conflicts.length}</span>`
: '';
const nameHtml = hopName
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(hopName)}</a>${hopEntry?.ambiguous ? ' ⚠' : ''}`
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(hopName)}</a>${conflictInfo}`
: '';
const label = hopName ? `Hop ${i}${nameHtml}` : `Hop ${i}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
+3 -1
View File
@@ -1224,7 +1224,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
+61 -25
View File
@@ -2003,25 +2003,47 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
app.get('/api/resolve-hops', (req, res) => {
const hops = (req.query.hops || '').split(',').filter(Boolean);
const observerId = req.query.observer || null;
// Origin anchor: sender's lat/lon for forward-pass disambiguation.
// Without this, the first ambiguous hop falls through to the backward pass
// which anchors from the observer — wrong when sender and observer are far apart.
const originLat = req.query.originLat ? parseFloat(req.query.originLat) : null;
const originLon = req.query.originLon ? parseFloat(req.query.originLon) : null;
if (!hops.length) return res.json({ resolved: {} });
const allNodes = getCachedNodes(false);
const allObservers = db.getObservers();
// Build observer IATA lookup and regional observer sets
const observerIataMap = {}; // observer_id → iata
const observersByIata = {}; // iata → Set<observer_id>
for (const obs of allObservers) {
if (obs.iata) {
observerIataMap[obs.id] = obs.iata;
if (!observersByIata[obs.iata]) observersByIata[obs.iata] = new Set();
observersByIata[obs.iata].add(obs.id);
}
}
// Determine this packet's region from its observer
const packetIata = observerId ? observerIataMap[observerId] : null;
const regionalObserverIds = packetIata ? observersByIata[packetIata] : null;
// Helper: check if a node has been seen by any observer in the given region
const nodeSeenInRegion = (pubkey) => {
if (!regionalObserverIds) return false;
const nodeObservers = pktStore._advertByObserver.get(pubkey);
if (!nodeObservers) return false;
for (const obsId of nodeObservers) {
if (regionalObserverIds.has(obsId)) return true;
}
return false;
};
// Build observer geographic position
let observerLat = null, observerLon = null;
if (observerId) {
// Try exact name match first
const obsNode = allNodes.find(n => n.name === observerId);
if (obsNode && obsNode.lat && obsNode.lon && !(obsNode.lat === 0 && obsNode.lon === 0)) {
observerLat = obsNode.lat;
observerLon = obsNode.lon;
} else {
// Fall back to averaging nearby nodes from adverts this observer received
const obsNodes = db.db.prepare(`
SELECT n.lat, n.lon FROM packets_v p
JOIN nodes n ON n.public_key = json_extract(p.decoded_json, '$.pubKey')
@@ -2040,25 +2062,44 @@ app.get('/api/resolve-hops', (req, res) => {
}
const resolved = {};
// First pass: find all candidates for each hop
// First pass: find all candidates for each hop, split into regional and global
for (const hop of hops) {
const hopLower = hop.toLowerCase();
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 }] };
const hopByteLen = Math.ceil(hop.length / 2); // 2 hex chars = 1 byte
const allCandidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
resolved[hop] = { name: c.name, pubkey: c.public_key, candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon }], conflicts: [] };
} 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 })) };
// Multiple candidates — apply regional filtering
const regional = allCandidates.filter(c => nodeSeenInRegion(c.public_key));
const candidates = regional.length > 0 ? regional : allCandidates;
const globalFallback = regional.length === 0 && allCandidates.length > 0;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: nodeSeenInRegion(c.public_key)
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: hopByteLen, totalGlobal: allCandidates.length, totalRegional: regional.length };
}
}
}
// Sequential disambiguation: each hop must be near the previous one
// Walk the path forward, resolving ambiguous hops by distance to last known position
// Start from first unambiguous hop (or observer position as anchor for last hop)
// Build initial resolved positions map
const hopPositions = {}; // hop -> {lat, lon}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
const hopPositions = {};
// Seed unambiguous positions
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
@@ -2069,9 +2110,6 @@ app.get('/api/resolve-hops', (req, res) => {
}
}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
for (let hi = 0; hi < hops.length; hi++) {
const hop = hops[hi];
@@ -2084,7 +2122,6 @@ app.get('/api/resolve-hops', (req, res) => {
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
// Use previous hop position, or observer position for last hop, or skip
let anchor = lastPos;
if (!anchor && hi === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
@@ -2117,7 +2154,7 @@ app.get('/api/resolve-hops', (req, res) => {
nextPos = hopPositions[hop];
}
// Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°)
// Sanity check: drop hops impossibly far from both neighbors
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
for (let i = 0; i < hops.length; i++) {
const pos = hopPositions[hops[i]];
@@ -2130,14 +2167,13 @@ app.get('/api/resolve-hops', (req, res) => {
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
const tooFarNext = next && dNext > MAX_HOP_DIST;
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
// Mark as unreliable — likely prefix collision with distant node
const r = resolved[hops[i]];
if (r) { r.unreliable = true; }
delete hopPositions[hops[i]];
}
}
res.json({ resolved });
res.json({ resolved, region: packetIata || null });
});
// channelHashNames removed — we only use decoded channel names now