diff --git a/public/analytics.js b/public/analytics.js index e4826b1a..8556b2f8 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5707,12 +5707,30 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf .filter(Boolean) .reverse(); // chronological — messages come back most-recent-first + // Comparison: for messages where the sender ALSO shared a real + // position, pair it with that same message's resolved entry-point + // repeater — lets us see how far the nearest-repeater proxy was + // from where the sender actually said they were. + var comparisonHits = messages + .filter(function(m) { return m.lat != null && m.lon != null && m.pathPrefixes && m.pathPrefixes.length > 0; }) + .map(function(m) { + var r = resolved[m.pathPrefixes[0]]; + if (!r || r.confidence !== 'unique_prefix' || !r.pubkey) return null; + return { transmissionId: m.transmissionId, pubkey: r.pubkey, name: r.name, timestamp: m.timestamp, gpsLat: m.lat, gpsLon: m.lon }; + }) + .filter(Boolean) + .reverse(); + var entryTrailPoints = []; - if (entryHits.length >= 2) { + var comparisonGpsPoints = []; + var comparisonEntryPoints = []; + var distanceByTxId = {}; + if (entryHits.length >= 2 || comparisonHits.length >= 1) { try { var nodesResp = await fetchAllNodes('', { ttl: CLIENT_TTL.nodeList }); var nodesByKey = {}; (nodesResp.nodes || []).forEach(function(n) { nodesByKey[n.public_key] = n; }); + var lastKey = null; entryHits.forEach(function(hit) { if (hit.pubkey === lastKey) return; // collapse consecutive hits on the same repeater @@ -5722,7 +5740,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf lastKey = hit.pubkey; } }); - } catch (e) { /* leave entryTrailPoints empty — button just won't show */ } + + comparisonHits.forEach(function(hit) { + var node = nodesByKey[hit.pubkey]; + if (!node || node.lat == null || node.lon == null) return; + var km = (window.HopResolver && typeof window.HopResolver.haversineKm === 'function') + ? window.HopResolver.haversineKm(hit.gpsLat, hit.gpsLon, node.lat, node.lon) : null; + distanceByTxId[hit.transmissionId] = { km: km, name: hit.name }; + comparisonGpsPoints.push({ lat: hit.gpsLat, lon: hit.gpsLon, timestamp: hit.timestamp, label: km != null ? (km.toFixed(1) + ' km from ' + hit.name) : 'Shared position' }); + comparisonEntryPoints.push({ lat: node.lat, lon: node.lon, timestamp: hit.timestamp, label: hit.name }); + }); + } catch (e) { /* leave arrays empty — buttons just won't show */ } } var rows = messages.map(function(m) { @@ -5734,7 +5762,14 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return esc(o.observerName) + ' (' + o.snr.toFixed(1) + 'dB / ' + o.rssi.toFixed(0) + 'dBm)'; }).join(', ') : '—'; - var posStr = (m.lat != null && m.lon != null) ? mapLinkHtml(m.lat, m.lon) : '—'; + var posStr = '—'; + if (m.lat != null && m.lon != null) { + posStr = mapLinkHtml(m.lat, m.lon); + var dist = distanceByTxId[m.transmissionId]; + if (dist && dist.km != null) { + posStr += ' (' + dist.km.toFixed(1) + ' km from ' + esc(dist.name) + ')'; + } + } return '' + (typeof timeAgo === 'function' ? timeAgo(m.timestamp) : m.timestamp) + '' + '' + pathStr + '' + '' + obsStr + '' + @@ -5751,10 +5786,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var entryBtnHtml = entryTrailPoints.length >= 2 ? '' : ''; - var btnRow = (pathBtnHtml || entryBtnHtml) ? '
' + pathBtnHtml + entryBtnHtml + '
' : ''; - return { html: btnRow + tableHtml, gpsPoints: gpsPoints, entryTrailPoints: entryTrailPoints }; + var compareBtnHtml = comparisonGpsPoints.length >= 1 + ? '' + : ''; + var btnRow = (pathBtnHtml || entryBtnHtml || compareBtnHtml) ? '
' + pathBtnHtml + entryBtnHtml + compareBtnHtml + '
' : ''; + return { + html: btnRow + tableHtml, gpsPoints: gpsPoints, entryTrailPoints: entryTrailPoints, + comparisonGpsPoints: comparisonGpsPoints, comparisonEntryPoints: comparisonEntryPoints + }; } catch (err) { - return { html: '

Failed to load messages: ' + esc(String(err)) + '

', gpsPoints: [], entryTrailPoints: [] }; + return { + html: '

Failed to load messages: ' + esc(String(err)) + '

', + gpsPoints: [], entryTrailPoints: [], comparisonGpsPoints: [], comparisonEntryPoints: [] + }; } } @@ -5803,6 +5847,16 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window.location.hash = '#/map'; }); } + var compareBtn = cell.querySelector('[data-wd-view-compare]'); + if (compareBtn) { + compareBtn.addEventListener('click', function() { + sessionStorage.setItem('map-gps-trail', JSON.stringify({ + points: result.comparisonGpsPoints, comparisonPoints: result.comparisonEntryPoints, + sender: senderName, kind: 'compare' + })); + window.location.hash = '#/map'; + }); + } }); }); } diff --git a/public/map.js b/public/map.js index 2d1319bb..0ff3f2b6 100644 --- a/public/map.js +++ b/public/map.js @@ -605,7 +605,7 @@ try { const parsed = JSON.parse(gpsTrailJson); if (parsed && Array.isArray(parsed.points)) { - drawGPSTrail(parsed.points, { sender: parsed.sender, kind: parsed.kind }); + drawGPSTrail(parsed.points, { sender: parsed.sender, kind: parsed.kind, comparisonPoints: parsed.comparisonPoints }); } } catch {} return; @@ -873,13 +873,19 @@ // Wardriving trail — draws a polyline through a sequence of points in // chronological order (see "View path on map" / "View approximate path - // via entry points" in the Wardriving analytics tab). Two flavors, both - // pre-resolved to plain lat/lon by the caller so no node resolution is - // needed here: + // via entry points" / "Compare shared position vs. entry point" in the + // Wardriving analytics tab). All pre-resolved to plain lat/lon by the + // caller so no node resolution is needed here: // - opts.kind 'gps' (default): a sender's own literal shared positions. // - opts.kind 'entry-point': for senders who don't share GPS — each // point is the KNOWN position of the entry-point repeater that first // relayed one of their messages, not the sender's real position. + // - opts.kind 'compare': `points` is the sender's real shared positions + // for messages that ALSO have a resolvable entry-point repeater; + // opts.comparisonPoints (same length, same order) is that repeater's + // position for each corresponding message — drawn as a second trail + // with a thin connecting line per pair, to visualize how far the + // nearest-repeater proxy was from the sender's real position. function drawGPSTrail(points, opts) { opts = opts || {}; if (markerLayer) map.removeLayer(markerLayer); @@ -906,6 +912,7 @@ const valid = (points || []).filter(function (p) { return p && p.lat != null && p.lon != null; }); if (valid.length === 0) return; + const compValid = (opts.comparisonPoints || []).filter(function (p) { return p && p.lat != null && p.lon != null; }); const coords = valid.map(function (p) { return [p.lat, p.lon]; }); if (coords.length >= 2) { @@ -922,10 +929,31 @@ marker.bindPopup(label); }); - if (coords.length >= 2) { - map.fitBounds(L.latLngBounds(coords).pad(0.2)); + // Comparison overlay: the entry-point repeater for each of the SAME + // messages, plus a thin dashed line per pair showing the offset. + const compCoords = compValid.map(function (p) { return [p.lat, p.lon]; }); + if (compCoords.length >= 2) { + L.polyline(compCoords, { color: '#f97316', weight: 2, opacity: 0.7, dashArray: '4 4' }).addTo(routeLayer); + } + compValid.forEach(function (p, i) { + const marker = L.circleMarker([p.lat, p.lon], { + radius: 5, color: '#f97316', fillColor: '#f97316', fillOpacity: 0.75, weight: 1 + }).addTo(routeLayer); + const label = safeEsc(p.label || 'Entry point ' + (i + 1)) + + (p.timestamp ? ' — ' + safeEsc(new Date(p.timestamp).toLocaleString()) : ''); + marker.bindPopup(label); + if (valid[i]) { + L.polyline([[valid[i].lat, valid[i].lon], [p.lat, p.lon]], { + color: '#94a3b8', weight: 1, opacity: 0.6, dashArray: '2 5' + }).addTo(routeLayer); + } + }); + + const allCoords = coords.concat(compCoords); + if (allCoords.length >= 2) { + map.fitBounds(L.latLngBounds(allCoords).pad(0.2)); } else { - map.setView(coords[0], 15); + map.setView(allCoords[0], 15); } if (opts.sender) { @@ -933,9 +961,13 @@ const label = document.createElement('div'); label.className = 'mc-gps-trail-label'; label.style.cssText = 'position:absolute;top:10px;left:50px;z-index:1000;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);padding:4px 10px;border-radius:4px;font-size:12px'; - label.textContent = opts.kind === 'entry-point' - ? opts.sender + ' — approximate path via ' + valid.length + ' entry-point repeater' + (valid.length === 1 ? '' : 's') + ' (not their real position)' - : opts.sender + ' — ' + valid.length + ' shared position' + (valid.length === 1 ? '' : 's'); + if (opts.kind === 'entry-point') { + label.textContent = opts.sender + ' — approximate path via ' + valid.length + ' entry-point repeater' + (valid.length === 1 ? '' : 's') + ' (not their real position)'; + } else if (opts.kind === 'compare') { + label.textContent = opts.sender + ' — shared position (blue/red/green) vs. entry-point repeater (orange), ' + valid.length + ' message' + (valid.length === 1 ? '' : 's'); + } else { + label.textContent = opts.sender + ' — ' + valid.length + ' shared position' + (valid.length === 1 ? '' : 's'); + } container.appendChild(label); } }