diff --git a/cmd/server/db.go b/cmd/server/db.go index 83d08dc2..85b07f85 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -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 } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 16461c2e..c11473dd 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -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 diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 2722657b..37642972 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -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{}{ diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 223adf51..7b6ae3c5 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -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, diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 0ceacd58..5fd55f04 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -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,