diff --git a/public/hop-resolver.js b/public/hop-resolver.js index b84547a7..919e043e 100644 --- a/public/hop-resolver.js +++ b/public/hop-resolver.js @@ -8,6 +8,7 @@ window.HopResolver = (function() { const MAX_HOP_DIST = 1.8; // ~200km in degrees const REGION_RADIUS_KM = 300; let prefixIdx = {}; // lowercase hex prefix → [node, ...] + let pubkeyIdx = {}; // full lowercase pubkey → node (O(1) lookup) let nodesList = []; let observerIataMap = {}; // observer_id → iata let iataCoords = {}; // iata → {lat, lon} @@ -35,9 +36,11 @@ window.HopResolver = (function() { function init(nodes, opts) { nodesList = nodes || []; prefixIdx = {}; + pubkeyIdx = {}; for (const n of nodesList) { if (!n.public_key) continue; const pk = n.public_key.toLowerCase(); + pubkeyIdx[pk] = n; for (let len = 1; len <= 3; len++) { const p = pk.slice(0, len * 2); if (!prefixIdx[p]) prefixIdx[p] = []; @@ -265,5 +268,30 @@ window.HopResolver = (function() { return affinityMap[pubkeyA][pubkeyB] || 0; } - return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity }; + /** + * Resolve hops using server-provided resolved_path (full pubkeys). + * Returns the same format as resolve() — { [hop]: { name, pubkey, ... } }. + * resolved_path is an array aligned with path_json: each element is a + * 64-char lowercase hex pubkey or null. Skips entries that are null. + */ + function resolveFromServer(hops, resolvedPath) { + if (!hops || !resolvedPath || hops.length !== resolvedPath.length) return {}; + var result = {}; + for (var i = 0; i < hops.length; i++) { + var hop = hops[i]; + var pubkey = resolvedPath[i]; + if (!pubkey) continue; // null = unresolved, leave for client-side fallback + // O(1) lookup via pubkeyIdx built during init() + var node = pubkeyIdx[pubkey.toLowerCase()] || null; + result[hop] = { + name: node ? node.name : pubkey.slice(0, 8), + pubkey: pubkey, + candidates: node ? [{ name: node.name, pubkey: pubkey, lat: node.lat, lon: node.lon }] : [], + conflicts: [] + }; + } + return result; + } + + return { init: init, resolve: resolve, resolveFromServer: resolveFromServer, ready: ready, haversineKm: haversineKm, setAffinity: setAffinity, getAffinity: getAffinity }; })(); diff --git a/public/live.js b/public/live.js index 4cc6b763..50562212 100644 --- a/public/live.js +++ b/public/live.js @@ -457,6 +457,7 @@ id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex, path_json: pkt.path_json, + resolved_path: pkt.resolved_path, _ts: new Date(pkt.timestamp || pkt.created_at).getTime(), decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } }, snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name @@ -1861,7 +1862,7 @@ var pathKey = hops.join(','); if (seenPathKeys.has(pathKey)) continue; seenPathKeys.add(pathKey); - var hopPositions = resolveHopPositions(hops, qp); + var hopPositions = resolveHopPositions(hops, qp, window.getResolvedPath ? getResolvedPath(qpkt) : null); if (hopPositions.length >= 2) { allPaths.push({ hopPositions: hopPositions, raw: qpkt.raw || first.raw }); } else if (hopPositions.length === 1) { @@ -1898,15 +1899,29 @@ } } - function resolveHopPositions(hops, payload) { - // Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing - const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null; - const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null; + function resolveHopPositions(hops, payload, resolvedPath) { + // Prefer server-side resolved_path when available + var resolvedMap; + if (resolvedPath && resolvedPath.length === hops.length && window.HopResolver && HopResolver.ready()) { + resolvedMap = HopResolver.resolveFromServer(hops, resolvedPath); + // Fill in any null entries from client-side fallback, preserving sender GPS context + var nullHops = hops.filter(function(h, i) { return !resolvedPath[i] && !resolvedMap[h]; }); + if (nullHops.length) { + const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null; + const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null; + var fallback = HopResolver.resolve(nullHops, originLat, originLon, null, null, null); + for (var k in fallback) resolvedMap[k] = fallback[k]; + } + } else { + // Delegate to shared HopResolver (from hop-resolver.js) instead of reimplementing + const originLat = payload.lat != null && !(payload.lat === 0 && payload.lon === 0) ? payload.lat : null; + const originLon = payload.lon != null && !(payload.lon === 0 && payload.lon === 0) ? payload.lon : null; - // Use HopResolver if available and initialized, otherwise fall back to simple lookup - const resolvedMap = (window.HopResolver && HopResolver.ready()) - ? HopResolver.resolve(hops, originLat, originLon, null, null, null) - : {}; + // Use HopResolver if available and initialized, otherwise fall back to simple lookup + resolvedMap = (window.HopResolver && HopResolver.ready()) + ? HopResolver.resolve(hops, originLat, originLon, null, null, null) + : {}; + } // Convert HopResolver's map format to the array format live.js expects: {key, pos, name, known} const raw = hops.map(hop => { diff --git a/public/packet-helpers.js b/public/packet-helpers.js index 3c20b382..a6bd9788 100644 --- a/public/packet-helpers.js +++ b/public/packet-helpers.js @@ -28,9 +28,27 @@ window.getParsedPath = function getParsedPath(p) { window.clearParsedCache = function clearParsedCache(p) { delete p._parsedPath; delete p._parsedDecoded; + delete p._parsedResolvedPath; return p; }; +/** + * Parse resolved_path (server-side resolved full pubkeys). + * Returns array of pubkey strings (or null entries) if present, or null if absent. + * Cached as _parsedResolvedPath on the packet object. + */ +window.getResolvedPath = function getResolvedPath(p) { + if (p._parsedResolvedPath !== undefined) return p._parsedResolvedPath; + var raw = p.resolved_path; + if (!raw) { p._parsedResolvedPath = null; return null; } + if (typeof raw !== 'string') { + p._parsedResolvedPath = Array.isArray(raw) ? raw : null; + return p._parsedResolvedPath; + } + try { p._parsedResolvedPath = JSON.parse(raw) || null; } catch (e) { p._parsedResolvedPath = null; } + return p._parsedResolvedPath; +}; + window.getParsedDecoded = function getParsedDecoded(p) { if (p._parsedDecoded !== undefined) return p._parsedDecoded || {}; var raw = p.decoded_json; diff --git a/public/packets.js b/public/packets.js index 0f5e21aa..83b47886 100644 --- a/public/packets.js +++ b/public/packets.js @@ -171,6 +171,29 @@ } } + /** + * Pre-populate hopNameCache from server-side resolved_path on packets. + * Packets with resolved_path skip client-side HopResolver entirely. + * Must call ensureHopResolver() first so nodesList is available for name lookup. + */ + async function cacheResolvedPaths(packets) { + if (!packets || !packets.length) return; + let needsInit = false; + for (const p of packets) { + const rp = getResolvedPath(p); + if (rp) { needsInit = true; break; } + } + if (!needsInit) return; + await ensureHopResolver(); + for (const p of packets) { + const rp = getResolvedPath(p); + if (!rp) continue; + const hops = getParsedPath(p); + const resolved = HopResolver.resolveFromServer(hops, rp); + Object.assign(hopNameCache, resolved); + } + } + function renderHop(h, observerId) { // Use per-packet cache key if observer context available (ambiguous hops differ by region) const cacheKey = observerId ? h + ':' + observerId : h; @@ -269,7 +292,7 @@ const obs = data.observations.find(o => String(o.id) === String(obsTarget)); if (obs) { expandedHashes.add(h); - const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, timestamp: obs.timestamp, first_seen: obs.timestamp}; + const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, timestamp: obs.timestamp, first_seen: obs.timestamp}; clearParsedCache(obsPacket); selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id); } else { @@ -371,9 +394,16 @@ if (!filtered.length) return; // Resolve any new hops, then update and re-render + // Pre-populate from server-side resolved_path, then fall back for remaining const newHops = new Set(); for (const p of filtered) { - try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} + const rp = getResolvedPath(p); + const hops = getParsedPath(p); + if (rp && rp.length === hops.length && window.HopResolver && HopResolver.ready()) { + const resolved = HopResolver.resolveFromServer(hops, rp); + Object.assign(hopNameCache, resolved); + } + try { hops.forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} } (newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => { if (groupByHash) { @@ -524,7 +554,10 @@ totalCount = flat.length; } - // Pre-resolve all path hops to node names + // Pre-resolve from server-side resolved_path (preferred, no client-side disambiguation needed) + await cacheResolvedPaths(packets); + + // Pre-resolve all path hops to node names (fallback for packets without resolved_path) const allHops = new Set(); for (const p of packets) { try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {} @@ -1019,7 +1052,7 @@ const child = group?._children?.find(c => String(c.id) === String(value)); if (child) { const parentData = group._fetchedData; - const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, timestamp: child.timestamp, first_seen: child.timestamp} : child; + const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, timestamp: child.timestamp, first_seen: child.timestamp} : child; if (parentData) { clearParsedCache(obsPacket); } selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id); } @@ -1492,11 +1525,18 @@ } catch {} } - // Re-resolve hops using client-side HopResolver with sender GPS context + // Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver if (pathHops.length) { try { - await ensureHopResolver(); - const resolved = HopResolver.resolve(pathHops); + const serverResolved = getResolvedPath(pkt); + let resolved; + if (serverResolved && serverResolved.length === pathHops.length) { + await ensureHopResolver(); + resolved = HopResolver.resolveFromServer(pathHops, serverResolved); + } else { + await ensureHopResolver(); + resolved = HopResolver.resolve(pathHops); + } if (resolved) { for (const [k, v] of Object.entries(resolved)) { hopNameCache[k] = v; @@ -1673,22 +1713,25 @@ if (routeBtn && pathHops.length) { routeBtn.addEventListener('click', async () => { try { - // Anchor disambiguation from sender's location if known (e.g. ADVERT lat/lon) - const senderLat = decoded.lat || decoded.latitude; - const senderLon = decoded.lon || decoded.longitude; - // Resolve observer position for backward-pass anchor - let obsLat = null, obsLon = null; - const obsId = obsName(pkt.observer_id); - if (obsId && HopResolver.ready()) { - // Try to find observer in nodes list by name — best effort + // Prefer server-side resolved_path if available + const serverResolved = getResolvedPath(pkt); + let resolvedKeys; + if (serverResolved && serverResolved.length === pathHops.length) { + // Use server-resolved pubkeys, fall back to short prefix for null entries + resolvedKeys = pathHops.map((h, i) => serverResolved[i] || h); + } else { + // Fall back to client-side HopResolver + const senderLat = decoded.lat || decoded.latitude; + const senderLon = decoded.lon || decoded.longitude; + let obsLat = null, obsLon = null; + const obsId = obsName(pkt.observer_id); + await ensureHopResolver(); + const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) }; + resolvedKeys = pathHops.map(h => { + const r = data.resolved?.[h]; + return r?.pubkey || h; + }); } - await ensureHopResolver(); - 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]; - return r?.pubkey || h; - }); // Build origin info for the sender node const origin = {}; if (decoded.pubKey) origin.pubkey = decoded.pubKey; @@ -2019,7 +2062,8 @@ // Sort children based on current sort mode sortGroupChildren(group); } - // Resolve any new hops from children + // Resolve hops from children: prefer server-side resolved_path + await cacheResolvedPaths(group?._children || []); const childHops = new Set(); for (const c of (group?._children || [])) { try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {} diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 0edbc4b0..6e8ed4c1 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -564,6 +564,93 @@ console.log('\n=== hop-resolver.js ==='); }); } +// ===== resolveFromServer (hop-resolver.js, M4 #555) ===== +console.log('\n=== resolveFromServer (hop-resolver.js) ==='); +{ + const ctx = makeSandbox(); + ctx.IATA_COORDS_GEO = {}; + loadInCtx(ctx, 'public/hop-resolver.js'); + const HR = ctx.window.HopResolver; + + test('resolveFromServer works without init (uses pubkey prefix as name)', () => { + const pk = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const result = HR.resolveFromServer(['AB'], [pk]); + assert.strictEqual(result['AB'].name, pk.slice(0, 8)); + assert.strictEqual(result['AB'].pubkey, pk); + }); + + test('resolveFromServer with matching node', () => { + const pubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + HR.init([{ public_key: pubkey, name: 'NodeA', lat: 37.3, lon: -122.0 }]); + const result = HR.resolveFromServer(['AB'], [pubkey]); + assert.strictEqual(result['AB'].name, 'NodeA'); + assert.strictEqual(result['AB'].pubkey, pubkey); + assert.ok(!result['AB'].ambiguous); + }); + + test('resolveFromServer with null entry skips it', () => { + const pubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + HR.init([{ public_key: pubkey, name: 'NodeA', lat: 37.3, lon: -122.0 }]); + const result = HR.resolveFromServer(['AB', 'CD'], [pubkey, null]); + assert.strictEqual(result['AB'].name, 'NodeA'); + assert.ok(!('CD' in result)); // null entries are skipped + }); + + test('resolveFromServer with unknown pubkey uses prefix', () => { + HR.init([{ public_key: 'aaaa0000', name: 'Other' }]); + const unknownPk = '1111111111111111111111111111111111111111111111111111111111111111'; + const result = HR.resolveFromServer(['AB'], [unknownPk]); + assert.strictEqual(result['AB'].name, unknownPk.slice(0, 8)); + assert.strictEqual(result['AB'].pubkey, unknownPk); + }); + + test('resolveFromServer mismatched lengths returns empty', () => { + HR.init([{ public_key: 'abcdef1234567890', name: 'NodeA' }]); + const result = HR.resolveFromServer(['AB', 'CD'], ['abcdef1234567890']); + assert.strictEqual(Object.keys(result).length, 0); + }); +} + +// ===== getResolvedPath (packet-helpers.js, M4 #555) ===== +console.log('\n=== getResolvedPath (packet-helpers.js) ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/packet-helpers.js'); + const getResolvedPath = ctx.window.getResolvedPath; + + test('getResolvedPath returns null when absent', () => { + assert.strictEqual(getResolvedPath({}), null); + }); + + test('getResolvedPath parses JSON string', () => { + const pkt = { resolved_path: '["aabb","ccdd",null]' }; + const result = getResolvedPath(pkt); + assert.deepStrictEqual(result, ['aabb', 'ccdd', null]); + }); + + test('getResolvedPath returns array as-is', () => { + const arr = ['aabb', null]; + const pkt = { resolved_path: arr }; + assert.strictEqual(getResolvedPath(pkt), arr); + }); + + test('getResolvedPath caches result', () => { + const pkt = { resolved_path: '["aabb"]' }; + const r1 = getResolvedPath(pkt); + const r2 = getResolvedPath(pkt); + assert.strictEqual(r1, r2); // same reference + }); + + test('clearParsedCache clears resolved path cache', () => { + const clearParsedCache = ctx.window.clearParsedCache; + const pkt = { resolved_path: '["aabb"]' }; + getResolvedPath(pkt); + assert.ok(pkt._parsedResolvedPath !== undefined); + clearParsedCache(pkt); + assert.strictEqual(pkt._parsedResolvedPath, undefined); + }); +} + // ===== haversineKm exposed from HopResolver (issue #433) ===== console.log('\n=== haversineKm (hop-resolver.js) ==='); {