mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-22 23:41:41 +00:00
feat: View Path shows distance from 'first' in km
Adds DistanceFromFirstKm to PacketPathBranch: the great-circle distance (haversine) between a branch's own Observer and First's Observer. Zero for First itself. Deliberately omitted when either side is positioned via Approx (a neighbor-centroid estimate) -- a distance computed against a guess isn't a real measurement worth surfacing. Rendered in the observer tooltip as "42.3 km away" alongside the elapsed-time label, giving a concrete sense of how far the flood physically reached, not just how many hops or how long it took. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
575743efda
commit
3c8ec35ea2
5 files changed
+135
-10
No files matched your search
+26
-5
@@ -1591,6 +1591,12 @@ type PacketPathBranch struct {
|
||||
// deepest observation arrived, in seconds. Zero for First itself.
|
||||
// Omitted when either timestamp is unknown.
|
||||
SecondsAfterFirst *float64 `json:"secondsAfterFirst,omitempty"`
|
||||
// DistanceFromFirstKm is the great-circle distance between this
|
||||
// branch's own Observer and First's Observer. Zero for First itself.
|
||||
// Omitted when either station's position is unknown (including when
|
||||
// one or both are Approx -- an estimate compounding another estimate
|
||||
// isn't worth surfacing).
|
||||
DistanceFromFirstKm *float64 `json:"distanceFromFirstKm,omitempty"`
|
||||
}
|
||||
|
||||
// PacketPathResponse is every branch a packet is known to have reached --
|
||||
@@ -1922,16 +1928,31 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) {
|
||||
return branch
|
||||
}
|
||||
|
||||
for _, b := range best {
|
||||
resp.Branches = append(resp.Branches, buildBranch(b))
|
||||
}
|
||||
sort.Slice(resp.Branches, func(i, j int) bool { return resp.Branches[i].Hops > resp.Branches[j].Hops })
|
||||
|
||||
// Build First first so its Observer position is known before computing
|
||||
// every other branch's DistanceFromFirstKm against it.
|
||||
var firstLat, firstLon *float64
|
||||
if first != nil {
|
||||
fb := buildBranch(first)
|
||||
if fb.Observer != nil && !fb.Observer.Approx {
|
||||
firstLat, firstLon = fb.Observer.Lat, fb.Observer.Lon
|
||||
}
|
||||
if firstLat != nil {
|
||||
zero := 0.0
|
||||
fb.DistanceFromFirstKm = &zero
|
||||
}
|
||||
resp.First = &fb
|
||||
}
|
||||
|
||||
for _, b := range best {
|
||||
branch := buildBranch(b)
|
||||
if firstLat != nil && branch.Observer != nil && !branch.Observer.Approx && branch.Observer.Lat != nil {
|
||||
d := haversineKm(*branch.Observer.Lat, *branch.Observer.Lon, *firstLat, *firstLon)
|
||||
branch.DistanceFromFirstKm = &d
|
||||
}
|
||||
resp.Branches = append(resp.Branches, branch)
|
||||
}
|
||||
sort.Slice(resp.Branches, func(i, j int) bool { return resp.Branches[i].Hops > resp.Branches[j].Hops })
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -745,6 +745,79 @@ func TestGetPacketPath_First(t *testing.T) {
|
||||
if mid.SecondsAfterFirst == nil || *mid.SecondsAfterFirst != 200 {
|
||||
t.Errorf("Observer Mid.SecondsAfterFirst = %v, want 200 (arrived at ts=300, 200s after First's ts=100)", mid.SecondsAfterFirst)
|
||||
}
|
||||
|
||||
// SJC/SFO/OAK (Observer Early/Deep/Mid's IATA positions) are all
|
||||
// real, non-approx Bay Area airport coordinates -- distances should
|
||||
// be computed, First's own distance is exactly 0, and the others are
|
||||
// a real (bounded, Bay-Area-scale) positive distance.
|
||||
if resp.First.DistanceFromFirstKm == nil || *resp.First.DistanceFromFirstKm != 0 {
|
||||
t.Errorf("First.DistanceFromFirstKm = %v, want 0 -- it defines the reference point", resp.First.DistanceFromFirstKm)
|
||||
}
|
||||
if deep.DistanceFromFirstKm == nil || *deep.DistanceFromFirstKm <= 0 || *deep.DistanceFromFirstKm > 200 {
|
||||
t.Errorf("Branches[0].DistanceFromFirstKm = %v, want a positive, Bay-Area-scale distance from SJC to SFO", deep.DistanceFromFirstKm)
|
||||
}
|
||||
if mid.DistanceFromFirstKm == nil || *mid.DistanceFromFirstKm <= 0 || *mid.DistanceFromFirstKm > 200 {
|
||||
t.Errorf("Observer Mid.DistanceFromFirstKm = %v, want a positive, Bay-Area-scale distance from SJC to OAK", mid.DistanceFromFirstKm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPacketPath_DistanceOmittedWhenApprox covers the "don't compound
|
||||
// an estimate on top of another estimate" rule: a branch whose Observer
|
||||
// position is itself Approx (borrowed from a neighbor, see
|
||||
// nearestPositionedNeighbor) must not get a DistanceFromFirstKm, even
|
||||
// though First has a real position -- the result would be a distance to
|
||||
// a guess, not a real measurement.
|
||||
func TestGetPacketPath_DistanceOmittedWhenApprox(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
db.conn.Exec(`CREATE TABLE IF NOT EXISTS neighbor_edges (node_a TEXT NOT NULL, node_b TEXT NOT NULL, count INTEGER DEFAULT 1, last_seen TEXT, PRIMARY KEY (node_a, node_b))`)
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsfirst', 'Observer First', 'SJC')`)
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obsghost', 'Ghost Observer', NULL)`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES ('obsghost', 'Ghost Observer', 'repeater')`)
|
||||
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('pkanchor', 'AnchorRepeater', 'repeater', 55.5, 9.5)`)
|
||||
db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('obsghost', 'pkanchor', 10)`)
|
||||
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash)
|
||||
VALUES ('AA', 'pathtest00000011', '2026-01-15T10:00:00Z', 1, 5,
|
||||
'{"type":"CHAN","channel":"#ping","text":"ping","sender":"Eve"}', '#ping')`)
|
||||
// obsfirst: earliest (ts=100), real IATA position -- this is First.
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 9.0, -88, '[]', 100)`)
|
||||
// obsghost: later (ts=200), deeper (2 hops) -- Branches[0], but its
|
||||
// only position comes from the neighbor-centroid fallback (Approx).
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 4.0, -95, '["aa","bb"]', 200)`)
|
||||
|
||||
resp, err := db.GetPacketPath("pathtest00000011")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.First == nil || resp.First.Observer == nil || resp.First.Observer.Name != "Observer First" {
|
||||
t.Fatalf("First = %+v, want Observer First", resp.First)
|
||||
}
|
||||
if resp.First.Observer.Lat == nil {
|
||||
t.Fatalf("First.Observer.Lat = nil, want a real IATA-derived position")
|
||||
}
|
||||
// Two distinct observers each contribute their own branch (obsfirst's
|
||||
// own 0-hop observation is also a branch in its own right, separate
|
||||
// from it being First) -- find Ghost Observer's specifically.
|
||||
var ghost *PacketPathBranch
|
||||
for i := range resp.Branches {
|
||||
if resp.Branches[i].Observer != nil && resp.Branches[i].Observer.Name == "Ghost Observer" {
|
||||
ghost = &resp.Branches[i]
|
||||
}
|
||||
}
|
||||
if ghost == nil {
|
||||
t.Fatalf("Branches = %+v, want a Ghost Observer branch", resp.Branches)
|
||||
}
|
||||
if !ghost.Observer.Approx {
|
||||
t.Fatalf("Ghost Observer.Approx = false, want true (positioned only via the neighbor fallback)")
|
||||
}
|
||||
if ghost.DistanceFromFirstKm != nil {
|
||||
t.Errorf("Ghost Observer.DistanceFromFirstKm = %v, want nil -- its own position is itself an estimate", ghost.DistanceFromFirstKm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPacketPath_ExcludesNullIsland covers a node whose nodes.lat/lon
|
||||
|
||||
@@ -368,11 +368,12 @@ func componentSchemas() map[string]interface{} {
|
||||
"type": "object",
|
||||
"description": "One station's own route to a packet: how far it traveled to reach them (from that observation's raw hop count, independent of how much of it resolved) and, where resolvable, each hop's position in path order.",
|
||||
"properties": map[string]interface{}{
|
||||
"hops": map[string]interface{}{"type": "integer", "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved."},
|
||||
"points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved."},
|
||||
"observer": schemaRef("PacketPathObserver"),
|
||||
"snr": map[string]interface{}{"type": "number", "nullable": true, "description": "SNR of this station's deepest observation."},
|
||||
"secondsAfterFirst": map[string]interface{}{"type": "number", "description": "Seconds after the earliest-arriving observation (see PacketPathResponse.first) this branch's own observation arrived. Zero for first itself. Omitted when either timestamp is unknown."},
|
||||
"hops": map[string]interface{}{"type": "integer", "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved."},
|
||||
"points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved."},
|
||||
"observer": schemaRef("PacketPathObserver"),
|
||||
"snr": map[string]interface{}{"type": "number", "nullable": true, "description": "SNR of this station's deepest observation."},
|
||||
"secondsAfterFirst": map[string]interface{}{"type": "number", "description": "Seconds after the earliest-arriving observation (see PacketPathResponse.first) this branch's own observation arrived. Zero for first itself. Omitted when either timestamp is unknown."},
|
||||
"distanceFromFirstKm": map[string]interface{}{"type": "number", "description": "Great-circle distance (km) between this branch's own observer and first's observer. Zero for first itself. Omitted when either position is unknown, or when either observer is positioned via approx (an estimate compounding another estimate isn't worth surfacing)."},
|
||||
},
|
||||
},
|
||||
"PacketPathResponse": map[string]interface{}{
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
if (b.observer && b.observer.lat != null && b.observer.lon != null) {
|
||||
var observerLabel = b.hops + ' hop' + (b.hops === 1 ? '' : 's');
|
||||
if (typeof b.secondsAfterFirst === 'number') observerLabel += ', ' + formatElapsed(b.secondsAfterFirst);
|
||||
if (typeof b.distanceFromFirstKm === 'number' && b.distanceFromFirstKm > 0) observerLabel += ', ' + b.distanceFromFirstKm.toFixed(1) + ' km away';
|
||||
chain.push({
|
||||
lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name,
|
||||
label: observerLabel, isObserver: true, approx: !!b.observer.approx,
|
||||
|
||||
@@ -340,6 +340,35 @@ function makeSandbox(apiImpl) {
|
||||
} catch (e) { failed++; console.log(' ❌ secondsAfterFirst renders as an elapsed-time label in the tooltip: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// branch.distanceFromFirstKm (> 0) should show up in the observer's
|
||||
// tooltip label; exactly 0 (First itself) should not add a
|
||||
// redundant "0.0 km away".
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{ hops: 2, points: [], observer: { name: 'FarObserver', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 42.3 },
|
||||
],
|
||||
first: { hops: 0, points: [], observer: { name: 'FarObserver', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 0 },
|
||||
}));
|
||||
|
||||
let tooltips = [];
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
assert.ok(tooltips.some((t) => t.includes('42.3 km away')), 'expected a tooltip with the 42.3 km distance, got: ' + JSON.stringify(tooltips));
|
||||
assert.ok(!tooltips.some((t) => t.includes('0.0 km away')), 'did not expect a "0.0 km away" label, got: ' + JSON.stringify(tooltips));
|
||||
passed++;
|
||||
console.log(' ✅ distanceFromFirstKm renders as a "N km away" label in the tooltip');
|
||||
} catch (e) { failed++; console.log(' ❌ distanceFromFirstKm renders as a "N km away" label in the tooltip: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// A single-neighbor approx point should render with a bigger,
|
||||
|
||||
Reference in new issue
Block a user