diff --git a/public/hop-resolver.js b/public/hop-resolver.js index cd74aaab..8cad87f0 100644 --- a/public/hop-resolver.js +++ b/public/hop-resolver.js @@ -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 }; + } } } diff --git a/public/index.html b/public/index.html index dffaa2e8..3f8b6cb4 100644 --- a/public/index.html +++ b/public/index.html @@ -82,10 +82,10 @@ - + - + diff --git a/public/packets.js b/public/packets.js index a061821b..032772be 100644 --- a/public/packets.js +++ b/public/packets.js @@ -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]; diff --git a/server.js b/server.js index 56a981b3..f7366ad5 100644 --- a/server.js +++ b/server.js @@ -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 || {};