mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 19:40:59 +00:00
fix: replace Euclidean distance with haversine in analytics hop distances (#478)
## Summary Fixes #433 — Replace the inaccurate Euclidean distance approximation in `analytics.js` hop distances with proper haversine calculation, matching the server-side computation introduced in PR #415. ## Problem PR #415 moved collision analysis server-side and switched from the frontend's Euclidean approximation (`dLat×111, dLon×85`) to proper haversine. However, the **hop distance** calculation in `analytics.js` (subpath detail panel) still used the old Euclidean formula. This caused: - **Inconsistent distances** between hop distances and collision distances - **Significant errors at high latitudes** — e.g., Oslo→Stockholm: Euclidean gives ~627km, haversine gives ~415km (51% error) - The `dLon×85` constant assumes ~40° latitude; at 60° latitude the real scale factor is ~55.5km/degree, not 85 ## Changes | File | Change | |------|--------| | `public/analytics.js` | Replace `dLat*111, dLon*85` Euclidean with `HopResolver.haversineKm()` (with inline fallback) | | `public/hop-resolver.js` | Export `haversineKm` in the public API for reuse | | `test-frontend-helpers.js` | Add 4 tests: export check, zero distance, SF→LA accuracy, Euclidean vs haversine divergence | | `cmd/server/helpers_test.go` | Add `TestHaversineKm`: zero, SF→LA, symmetry, Oslo→Stockholm accuracy | | `public/index.html` | Cache buster bump | ## Performance No performance impact — `haversineKm` replaces an inline arithmetic expression with another inline arithmetic expression of identical O(1) complexity. Only called per hop pair in the subpath detail panel (typically <10 hops). ## Testing - `node test-frontend-helpers.js` — 248 passed, 0 failed - `go test -run TestHaversineKm` — PASS Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -345,3 +345,29 @@ func TestWriteJSON(t *testing.T) {
|
||||
t.Errorf("expected 'value', got %v", body["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaversineKm(t *testing.T) {
|
||||
// Same point should be 0
|
||||
if d := haversineKm(37.0, -122.0, 37.0, -122.0); d != 0 {
|
||||
t.Errorf("same point: expected 0, got %f", d)
|
||||
}
|
||||
|
||||
// SF to LA ~559km
|
||||
d := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
if d < 550 || d > 570 {
|
||||
t.Errorf("SF to LA: expected ~559km, got %f", d)
|
||||
}
|
||||
|
||||
// Symmetry
|
||||
d1 := haversineKm(37.7749, -122.4194, 34.0522, -118.2437)
|
||||
d2 := haversineKm(34.0522, -118.2437, 37.7749, -122.4194)
|
||||
if d1 != d2 {
|
||||
t.Errorf("not symmetric: %f vs %f", d1, d2)
|
||||
}
|
||||
|
||||
// Oslo to Stockholm ~415km (old Euclidean dLat*111, dLon*85 would give ~627km)
|
||||
d = haversineKm(59.9, 10.7, 59.3, 18.0)
|
||||
if d < 400 || d > 430 {
|
||||
t.Errorf("Oslo to Stockholm: expected ~415km, got %f", d)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1488,9 +1488,9 @@
|
||||
for (let i = 0; i < data.nodes.length - 1; i++) {
|
||||
const a = data.nodes[i], b = data.nodes[i+1];
|
||||
if (a.lat && a.lon && b.lat && b.lon && !(a.lat===0&&a.lon===0) && !(b.lat===0&&b.lon===0)) {
|
||||
const dLat = (a.lat - b.lat) * 111;
|
||||
const dLon = (a.lon - b.lon) * 85;
|
||||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
const km = window.HopResolver && window.HopResolver.haversineKm
|
||||
? window.HopResolver.haversineKm(a.lat, a.lon, b.lat, b.lon)
|
||||
: (() => { const R=6371, dLat=(b.lat-a.lat)*Math.PI/180, dLon=(b.lon-a.lon)*Math.PI/180, h=Math.sin(dLat/2)**2+Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h)); })();
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
|
||||
@@ -203,5 +203,5 @@ window.HopResolver = (function() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, ready: ready };
|
||||
return { init: init, resolve: resolve, ready: ready, haversineKm: haversineKm };
|
||||
})();
|
||||
|
||||
+28
-28
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1775110567">
|
||||
<link rel="stylesheet" href="home.css?v=1775110567">
|
||||
<link rel="stylesheet" href="live.css?v=1775110567">
|
||||
<link rel="stylesheet" href="style.css?v=1775111407">
|
||||
<link rel="stylesheet" href="home.css?v=1775111407">
|
||||
<link rel="stylesheet" href="live.css?v=1775111407">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -85,30 +85,30 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1775110567"></script>
|
||||
<script src="customize.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775110567"></script>
|
||||
<script src="hop-resolver.js?v=1775110567"></script>
|
||||
<script src="hop-display.js?v=1775110567"></script>
|
||||
<script src="app.js?v=1775110567"></script>
|
||||
<script src="home.js?v=1775110567"></script>
|
||||
<script src="packet-filter.js?v=1775110567"></script>
|
||||
<script src="packets.js?v=1775110567"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775110567"></script>
|
||||
<script src="map.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775110567" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1775111407"></script>
|
||||
<script src="customize.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1775111407"></script>
|
||||
<script src="hop-resolver.js?v=1775111407"></script>
|
||||
<script src="hop-display.js?v=1775111407"></script>
|
||||
<script src="app.js?v=1775111407"></script>
|
||||
<script src="home.js?v=1775111407"></script>
|
||||
<script src="packet-filter.js?v=1775111407"></script>
|
||||
<script src="packets.js?v=1775111407"></script>
|
||||
<script src="geo-filter-overlay.js?v=1775111407"></script>
|
||||
<script src="map.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1775111407" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -564,6 +564,40 @@ console.log('\n=== hop-resolver.js ===');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== haversineKm exposed from HopResolver (issue #433) =====
|
||||
console.log('\n=== haversineKm (hop-resolver.js) ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
ctx.IATA_COORDS_GEO = {};
|
||||
loadInCtx(ctx, 'public/hop-resolver.js');
|
||||
const HR = ctx.window.HopResolver;
|
||||
|
||||
test('haversineKm is exported', () => {
|
||||
assert.strictEqual(typeof HR.haversineKm, 'function');
|
||||
});
|
||||
|
||||
test('haversineKm same point = 0', () => {
|
||||
assert.strictEqual(HR.haversineKm(37.0, -122.0, 37.0, -122.0), 0);
|
||||
});
|
||||
|
||||
test('haversineKm SF to LA ~559km', () => {
|
||||
// San Francisco (37.7749, -122.4194) to Los Angeles (34.0522, -118.2437)
|
||||
const d = HR.haversineKm(37.7749, -122.4194, 34.0522, -118.2437);
|
||||
assert.ok(d > 550 && d < 570, `Expected ~559km, got ${d}`);
|
||||
});
|
||||
|
||||
test('haversineKm differs from old Euclidean approximation', () => {
|
||||
// The old code used dLat*111, dLon*85 which is inaccurate at high latitudes
|
||||
// Oslo (59.9, 10.7) to Stockholm (59.3, 18.0)
|
||||
const haversine = HR.haversineKm(59.9, 10.7, 59.3, 18.0);
|
||||
const dLat = (59.9 - 59.3) * 111;
|
||||
const dLon = (10.7 - 18.0) * 85;
|
||||
const euclidean = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
// Haversine should give ~415km, Euclidean ~627km (wrong because dLon*85 is wrong at 60° latitude)
|
||||
assert.ok(Math.abs(haversine - euclidean) > 50, `Expected significant difference, haversine=${haversine.toFixed(1)}, euclidean=${euclidean.toFixed(1)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== SNR/RSSI Number casting =====
|
||||
{
|
||||
// These test the pattern used in observer-detail.js, home.js, traces.js, live.js
|
||||
|
||||
Reference in New Issue
Block a user