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:
Kpa-clawbot
2026-04-01 23:37:01 -07:00
committed by GitHub
co-authored by you
parent 6712da7d7c
commit 96d0bbe487
5 changed files with 92 additions and 32 deletions
+26
View File
@@ -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
View File
@@ -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>`);
+1 -1
View File
@@ -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
View File
@@ -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>
+34
View File
@@ -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