feat: compare a GPS-sharing sender's real position against path[0]

For messages where a sender shared their real position AND have a
resolvable entry-point repeater, pairs them up: haversine distance
shown inline in the message table ("2.3 km from DK-XXX"), plus a new
"Compare shared position vs. entry point" drill-down button.

On the map (drawGPSTrail's new 'compare' kind), draws both trails —
real position (blue/red/green) and entry-point repeater (orange) —
with a thin dashed line connecting each matched pair, so you can see
at a glance how good the entry-point proxy is when ground truth is
available.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-20 17:17:21 +02:00
co-authored by Claude Sonnet 5
parent 23b650337f
commit 9d77d2ea96
2 changed files with 102 additions and 16 deletions
+60 -6
View File
@@ -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(', ')
: '<span class="text-muted">—</span>';
var posStr = (m.lat != null && m.lon != null) ? mapLinkHtml(m.lat, m.lon) : '<span class="text-muted">—</span>';
var posStr = '<span class="text-muted">—</span>';
if (m.lat != null && m.lon != null) {
posStr = mapLinkHtml(m.lat, m.lon);
var dist = distanceByTxId[m.transmissionId];
if (dist && dist.km != null) {
posStr += ' <span class="text-muted" style="font-size:0.9em">(' + dist.km.toFixed(1) + ' km from ' + esc(dist.name) + ')</span>';
}
}
return '<tr><td>' + (typeof timeAgo === 'function' ? timeAgo(m.timestamp) : m.timestamp) + '</td>' +
'<td>' + pathStr + '</td>' +
'<td>' + obsStr + '</td>' +
@@ -5751,10 +5786,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
var entryBtnHtml = entryTrailPoints.length >= 2
? '<button type="button" data-wd-view-entry-path style="' + btnStyle + '">View approximate path via entry points (' + entryTrailPoints.length + ' repeaters)</button>'
: '';
var btnRow = (pathBtnHtml || entryBtnHtml) ? '<div>' + pathBtnHtml + entryBtnHtml + '</div>' : '';
return { html: btnRow + tableHtml, gpsPoints: gpsPoints, entryTrailPoints: entryTrailPoints };
var compareBtnHtml = comparisonGpsPoints.length >= 1
? '<button type="button" data-wd-view-compare style="' + btnStyle + '">Compare shared position vs. entry point (' + comparisonGpsPoints.length + ')</button>'
: '';
var btnRow = (pathBtnHtml || entryBtnHtml || compareBtnHtml) ? '<div>' + pathBtnHtml + entryBtnHtml + compareBtnHtml + '</div>' : '';
return {
html: btnRow + tableHtml, gpsPoints: gpsPoints, entryTrailPoints: entryTrailPoints,
comparisonGpsPoints: comparisonGpsPoints, comparisonEntryPoints: comparisonEntryPoints
};
} catch (err) {
return { html: '<p style="color:var(--status-red);font-size:0.85em">Failed to load messages: ' + esc(String(err)) + '</p>', gpsPoints: [], entryTrailPoints: [] };
return {
html: '<p style="color:var(--status-red);font-size:0.85em">Failed to load messages: ' + esc(String(err)) + '</p>',
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';
});
}
});
});
}
+42 -10
View File
@@ -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);
}
}