mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 04:21:20 +00:00
Client-side regional hop filtering (#117)
HopResolver now mirrors server-side layered regional filtering: - init() accepts observers list + IATA coords - resolve() accepts observerId, looks up IATA, filters candidates by haversine distance (300km radius) to IATA center - Candidates include regional, filterMethod, distKm fields - Packet detail view passes observer_id to resolve() New endpoint: GET /api/iata-coords returns airport coordinates for client-side use. Fixes: conflict badges showing "0 conflicts" in packet detail because client-side resolver had no regional filtering.
This commit is contained in:
+73
-9
@@ -6,18 +6,32 @@ window.HopResolver = (function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_HOP_DIST = 1.8; // ~200km in degrees
|
||||
const REGION_RADIUS_KM = 300;
|
||||
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
|
||||
let nodesList = [];
|
||||
let observerIataMap = {}; // observer_id → iata
|
||||
let iataCoords = {}; // iata → {lat, lon}
|
||||
|
||||
function dist(lat1, lon1, lat2, lon2) {
|
||||
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
|
||||
}
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize (or rebuild) the prefix index from the full nodes list.
|
||||
* @param {Array} nodes - Array of {public_key, name, lat, lon, ...}
|
||||
* @param {Object} [opts] - Optional: { observers: [{id, iata}], iataCoords: {code: {lat,lon}} }
|
||||
*/
|
||||
function init(nodes) {
|
||||
function init(nodes, opts) {
|
||||
nodesList = nodes || [];
|
||||
prefixIdx = {};
|
||||
for (const n of nodesList) {
|
||||
@@ -29,6 +43,28 @@ window.HopResolver = (function() {
|
||||
prefixIdx[p].push(n);
|
||||
}
|
||||
}
|
||||
// Store observer IATA mapping and coords if provided
|
||||
observerIataMap = {};
|
||||
if (opts && opts.observers) {
|
||||
for (const o of opts.observers) {
|
||||
if (o.id && o.iata) observerIataMap[o.id] = o.iata;
|
||||
}
|
||||
}
|
||||
iataCoords = (opts && opts.iataCoords) || (window.IATA_COORDS_GEO) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is near an IATA region center.
|
||||
* Returns { near, method, distKm } or null.
|
||||
*/
|
||||
function nodeInRegion(candidate, iata) {
|
||||
const center = iataCoords[iata];
|
||||
if (!center) return null;
|
||||
if (candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
|
||||
const d = haversineKm(candidate.lat, candidate.lon, center.lat, center.lon);
|
||||
return { near: d <= REGION_RADIUS_KM, method: 'geo', distKm: Math.round(d) };
|
||||
}
|
||||
return null; // no GPS — can't geo-filter client-side
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,22 +78,50 @@ window.HopResolver = (function() {
|
||||
* @param {number|null} observerLon - Observer longitude (backward anchor)
|
||||
* @returns {Object} resolved map keyed by hop prefix
|
||||
*/
|
||||
function resolve(hops, originLat, originLon, observerLat, observerLon) {
|
||||
function resolve(hops, originLat, originLon, observerLat, observerLon, observerId) {
|
||||
if (!hops || !hops.length) return {};
|
||||
|
||||
// Determine observer's IATA for regional filtering
|
||||
const packetIata = observerId ? observerIataMap[observerId] : null;
|
||||
|
||||
const resolved = {};
|
||||
const hopPositions = {};
|
||||
|
||||
// First pass: find candidates
|
||||
// First pass: find candidates with regional filtering
|
||||
for (const hop of hops) {
|
||||
const h = hop.toLowerCase();
|
||||
const candidates = prefixIdx[h] || [];
|
||||
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 allCandidates = prefixIdx[h] || [];
|
||||
if (allCandidates.length === 0) {
|
||||
resolved[hop] = { name: null, candidates: [], conflicts: [] };
|
||||
} else if (allCandidates.length === 1) {
|
||||
const c = allCandidates[0];
|
||||
const regionCheck = packetIata ? nodeInRegion(c, packetIata) : null;
|
||||
resolved[hop] = { name: c.name, pubkey: c.public_key,
|
||||
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck ? regionCheck.near : false, filterMethod: regionCheck ? regionCheck.method : 'none', distKm: regionCheck ? regionCheck.distKm : undefined }],
|
||||
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 geo regional filtering
|
||||
const checked = allCandidates.map(c => {
|
||||
const r = packetIata ? nodeInRegion(c, packetIata) : null;
|
||||
return { ...c, regional: r ? r.near : false, filterMethod: r ? r.method : 'none', distKm: r ? r.distKm : undefined };
|
||||
});
|
||||
const regional = checked.filter(c => c.regional);
|
||||
const candidates = regional.length > 0 ? regional : checked;
|
||||
const globalFallback = regional.length === 0 && checked.length > 0 && packetIata != null;
|
||||
|
||||
const conflicts = candidates.map(c => ({
|
||||
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
|
||||
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
|
||||
}));
|
||||
|
||||
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: Math.ceil(hop.length / 2), totalGlobal: allCandidates.length, totalRegional: regional.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -82,10 +82,10 @@
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774325000"></script>
|
||||
<script src="region-filter.js?v=1774325000"></script>
|
||||
<script src="hop-resolver.js?v=1774126708"></script>
|
||||
<script src="hop-resolver.js?v=1774217881"></script>
|
||||
<script src="app.js?v=1774126708"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774215504"></script>
|
||||
<script src="packets.js?v=1774217881"></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>
|
||||
|
||||
+11
-4
@@ -99,12 +99,19 @@
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Ensure HopResolver is initialized with the nodes list
|
||||
// Ensure HopResolver is initialized with the nodes list + observer IATA data
|
||||
async function ensureHopResolver() {
|
||||
if (!HopResolver.ready()) {
|
||||
try {
|
||||
const data = await api('/nodes?limit=2000', { ttl: 60000 });
|
||||
HopResolver.init(data.nodes || []);
|
||||
const [nodeData, obsData, coordData] = await Promise.all([
|
||||
api('/nodes?limit=2000', { ttl: 60000 }),
|
||||
api('/observers', { ttl: 60000 }),
|
||||
api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })),
|
||||
]);
|
||||
HopResolver.init(nodeData.nodes || [], {
|
||||
observers: obsData.observers || obsData || [],
|
||||
iataCoords: coordData.coords || {},
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -1259,7 +1266,7 @@
|
||||
// Try to find observer in nodes list by name — best effort
|
||||
}
|
||||
await ensureHopResolver();
|
||||
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon) };
|
||||
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
|
||||
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
|
||||
const resolvedKeys = pathHops.map(h => {
|
||||
const r = data.resolved?.[h];
|
||||
|
||||
@@ -10,7 +10,12 @@ const fs = require('fs');
|
||||
const config = require('./config.json');
|
||||
const decoder = require('./decoder');
|
||||
const PAYLOAD_TYPES = decoder.PAYLOAD_TYPES;
|
||||
const { nodeNearRegion } = require('./iata-coords');
|
||||
const { nodeNearRegion, IATA_COORDS } = require('./iata-coords');
|
||||
|
||||
// IATA coordinates for client-side regional filtering
|
||||
app.get('/api/iata-coords', (req, res) => {
|
||||
res.json({ coords: IATA_COORDS });
|
||||
});
|
||||
|
||||
// Health thresholds — configurable with sensible defaults
|
||||
const _ht = config.healthThresholds || {};
|
||||
|
||||
Reference in New Issue
Block a user