diff --git a/cmd/server/db.go b/cmd/server/db.go index da517d50..2862722c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1527,10 +1527,19 @@ type PacketPathPoint struct { Role string `json:"role,omitempty"` Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` - // Approx is true when Lat/Lon are not this node's own position but - // its strongest neighbor_edges neighbor's position instead (used as - // a last-resort stand-in when the node itself has no known fix). + // Approx is true when Lat/Lon are a weighted centroid of this node's + // positioned neighbor_edges neighbors rather than its own position + // (used as a last-resort stand-in when the node itself has no known + // fix). ApproxNeighborCount/ApproxSpreadKm are only meaningful when + // Approx is true. Approx bool `json:"approx,omitempty"` + // ApproxNeighborCount is how many positioned neighbors fed the + // centroid -- a rough confidence signal, higher is more confident. + ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` + // ApproxSpreadKm is the widest distance (km) between any two of + // those neighbors -- 0 (and omitted) with a single contributor; + // larger means the neighbors disagree more about where "nearby" is. + ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` } // PacketPathObserver is the station that produced a given branch's @@ -1540,14 +1549,22 @@ type PacketPathPoint struct { // neighbor_edges neighbor's position (Approx=true), otherwise -- not a // stored per-observer lat/lon column. type PacketPathObserver struct { - Name string `json:"name"` - IATA string `json:"iata,omitempty"` - Lat *float64 `json:"lat"` - Lon *float64 `json:"lon"` - // Approx is true when Lat/Lon are not this station's own position - // but its strongest neighbor's position instead -- see - // PacketPathPoint.Approx. - Approx bool `json:"approx,omitempty"` + // PublicKey is the observer's mesh pubkey (observers.id for v3, + // observer_id for legacy), when it has one -- lets callers link out + // to the node detail page. Empty for an observer whose id never + // resembled a pubkey. + PublicKey string `json:"publicKey,omitempty"` + Name string `json:"name"` + IATA string `json:"iata,omitempty"` + Role string `json:"role,omitempty"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + // Approx is true when Lat/Lon are a weighted centroid of this + // station's positioned neighbors instead of its own position -- see + // PacketPathPoint.Approx (and ApproxNeighborCount/ApproxSpreadKm). + Approx bool `json:"approx,omitempty"` + ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` + ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` } // PacketPathBranch is one station's route to a packet: how far it @@ -1559,6 +1576,17 @@ type PacketPathBranch struct { Points []PacketPathPoint `json:"points"` Observer *PacketPathObserver `json:"observer,omitempty"` SNR *float64 `json:"snr,omitempty"` + // SecondsAfterFirst is how long after the earliest-arriving + // observation (see PacketPathResponse.First) this branch's own + // 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 -- @@ -1616,6 +1644,7 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { observerPubkey string observerIATA sql.NullString snr sql.NullFloat64 + ts int64 // unix epoch seconds of the observation that produced this branch's hops/resolvedPath; 0 if unknown } best := make(map[string]*obsBranch) var first *obsBranch @@ -1647,10 +1676,14 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { if key == "" { continue // no way to attribute this observation to a station } + var tsVal int64 + if ts.Valid { + tsVal = ts.Int64 + } branch := &obsBranch{ hops: hops, resolvedPath: resolvedPath, observerName: obsName.String, observerPubkey: strings.ToLower(strings.TrimSpace(obsPubkey.String)), - observerIATA: obsIATA, snr: snr, + observerIATA: obsIATA, snr: snr, ts: tsVal, } if existing, ok := best[key]; !ok || hops > existing.hops { best[key] = branch @@ -1814,17 +1847,22 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { point := PacketPathPoint{PublicKey: *pk, Name: name, Role: ni.role, Lat: ni.lat, Lon: ni.lon} if point.Lat == nil { // Last resort: this node has never itself reported a - // position -- borrow its strongest neighbor's instead, - // clearly flagged as approximate rather than a real fix. - if _, nLat, nLon, ok := db.nearestPositionedNeighbor(*pk); ok { + // position -- borrow a weighted centroid of its + // positioned neighbors instead, clearly flagged as + // approximate rather than a real fix. + if _, nLat, nLon, nCount, nSpread, ok := db.nearestPositionedNeighbor(*pk); ok { lat, lon := nLat, nLon - point.Lat, point.Lon, point.Approx = &lat, &lon, true + point.Lat, point.Lon, point.Approx, point.ApproxNeighborCount = &lat, &lon, true, nCount + if nCount > 1 { + s := nSpread + point.ApproxSpreadKm = &s + } } } branch.Points = append(branch.Points, point) } if b.observerName != "" { - obs := &PacketPathObserver{Name: b.observerName} + obs := &PacketPathObserver{Name: b.observerName, PublicKey: b.observerPubkey} if b.observerIATA.Valid { obs.IATA = strings.ToUpper(strings.TrimSpace(b.observerIATA.String)) } @@ -1839,6 +1877,11 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { // hand-added local codes, so a custom/regional code an // operator typed in (or a typo) falls through it even when // the node itself knows exactly where it is. + if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.role != "" { + obs.Role = ni.role + } else if ni, ok := nodeByName[b.observerName]; ok && ni.role != "" { + obs.Role = ni.role + } if ni, ok := nodeByPK[b.observerPubkey]; ok && ni.lat != nil && ni.lon != nil { obs.Lat, obs.Lon = ni.lat, ni.lon } else if ni, ok := nodeByName[b.observerName]; ok { @@ -1851,11 +1894,15 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { } if obs.Lat == nil && b.observerPubkey != "" { // Last resort, same as the hop-point fallback above: no - // position of its own anywhere, so borrow its strongest - // neighbor's instead, flagged as approximate. - if _, nLat, nLon, ok := db.nearestPositionedNeighbor(b.observerPubkey); ok { + // position of its own anywhere, so borrow a weighted + // centroid of its positioned neighbors, flagged as approximate. + if _, nLat, nLon, nCount, nSpread, ok := db.nearestPositionedNeighbor(b.observerPubkey); ok { lat, lon := nLat, nLon - obs.Lat, obs.Lon, obs.Approx = &lat, &lon, true + obs.Lat, obs.Lon, obs.Approx, obs.ApproxNeighborCount = &lat, &lon, true, nCount + if nCount > 1 { + s := nSpread + obs.ApproxSpreadKm = &s + } } } branch.Observer = obs @@ -1864,19 +1911,38 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { v := b.snr.Float64 branch.SNR = &v } + if b.ts > 0 && first != nil && first.ts > 0 { + d := float64(b.ts - first.ts) + branch.SecondsAfterFirst = &d + } 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 } @@ -1892,12 +1958,20 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { // each neighbor's OWN position is a real, precise fix; only pubkey's // position relative to them is unknown, so more of them narrows it // down. With exactly one positioned neighbor this is identical to -// using that neighbor's position outright. Returns ok=false when -// pubkey has no neighbor with a position at all. -func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon float64, ok bool) { +// using that neighbor's position outright. +// +// contributorCount is how many positioned neighbors fed the estimate, +// and spreadKm is the widest distance between any two of them (0 when +// there's only one) -- together a rough confidence signal callers can +// use to size an "uncertainty" marker: more contributors that broadly +// agree (small spread) means a tighter estimate than a single neighbor +// or several that disagree (large spread). +// +// Returns ok=false when pubkey has no neighbor with a position at all. +func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon float64, contributorCount int, spreadKm float64, ok bool) { pk := strings.ToLower(strings.TrimSpace(pubkey)) if pk == "" { - return "", 0, 0, false + return "", 0, 0, 0, 0, false } rows, err := db.conn.Query(` SELECT CASE WHEN node_a = ? THEN node_b ELSE node_a END AS neighbor, count @@ -1906,7 +1980,7 @@ func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon fl ORDER BY count DESC LIMIT 20`, pk, pk, pk) if err != nil { - return "", 0, 0, false + return "", 0, 0, 0, 0, false } type candidate struct { pubkey string @@ -1922,7 +1996,7 @@ func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon fl } rows.Close() if len(candidates) == 0 { - return "", 0, 0, false + return "", 0, 0, 0, 0, false } placeholders := make([]byte, 0, len(candidates)*2) @@ -1958,6 +2032,7 @@ func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon fl // otherwise cosmetic (current callers discard it). var sumLat, sumLon, sumWeight float64 var strongestName string + var contributors []posInfo for _, c := range candidates { p, found := posByPK[c.pubkey] if !found { @@ -1973,11 +2048,21 @@ func (db *DB) nearestPositionedNeighbor(pubkey string) (name string, lat, lon fl if strongestName == "" { strongestName = p.name } + contributors = append(contributors, p) } if sumWeight == 0 { - return "", 0, 0, false + return "", 0, 0, 0, 0, false } - return strongestName, sumLat / sumWeight, sumLon / sumWeight, true + var spread float64 + for i := 0; i < len(contributors); i++ { + for j := i + 1; j < len(contributors); j++ { + d := haversineKm(contributors[i].lat, contributors[i].lon, contributors[j].lat, contributors[j].lon) + if d > spread { + spread = d + } + } + } + return strongestName, sumLat / sumWeight, sumLon / sumWeight, len(contributors), spread, true } // GetChannels returns channel list from GRP_TXT packets. diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index fd520685..c11473dd 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -672,6 +672,9 @@ func TestGetPacketPath(t *testing.T) { if deep.Observer.Lat == nil || *deep.Observer.Lat != 37.6213 { t.Errorf("Branches[0].Observer.Lat = %v, want the SFO IATA coordinate (37.6213)", deep.Observer.Lat) } + if deep.Observer.PublicKey != "obs2" { + t.Errorf("Branches[0].Observer.PublicKey = %q, want obs2 (its observers.id)", deep.Observer.PublicKey) + } if shallow.Hops != 1 || shallow.Observer == nil || shallow.Observer.Name != "Observer One" { t.Fatalf("Branches[1] = %+v, want Observer One's 1-hop branch", shallow) } @@ -719,6 +722,102 @@ func TestGetPacketPath_First(t *testing.T) { if len(resp.Branches) == 0 || resp.Branches[0].Observer == nil || resp.Branches[0].Observer.Name != "Observer Deep" { t.Fatalf("Branches[0] = %+v, want Observer Deep still first (deepest-first ordering unaffected by First)", resp.Branches) } + + if resp.First.SecondsAfterFirst == nil || *resp.First.SecondsAfterFirst != 0 { + t.Errorf("First.SecondsAfterFirst = %v, want 0 -- it defines the reference point", resp.First.SecondsAfterFirst) + } + // Observer Deep arrived at timestamp=200, Observer Early (First) at + // timestamp=100 -- 100 seconds later. + deep := resp.Branches[0] + if deep.SecondsAfterFirst == nil || *deep.SecondsAfterFirst != 100 { + t.Errorf("Branches[0].SecondsAfterFirst = %v, want 100 (arrived at ts=200, 100s after First's ts=100)", deep.SecondsAfterFirst) + } + // Observer Mid arrived at timestamp=300 -- 200 seconds after First. + var mid *PacketPathBranch + for i := range resp.Branches { + if resp.Branches[i].Observer != nil && resp.Branches[i].Observer.Name == "Observer Mid" { + mid = &resp.Branches[i] + } + } + if mid == nil { + t.Fatalf("Branches = %+v, want an Observer Mid branch", resp.Branches) + } + 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 @@ -816,6 +915,12 @@ func TestGetPacketPath_FallsBackToSingleNeighborPosition(t *testing.T) { if p.Lat == nil || *p.Lat != 55.5 || p.Lon == nil || *p.Lon != 9.5 { t.Errorf("Points[0].Lat/Lon = %v/%v, want AnchorRepeater's exact position (55.5, 9.5) -- its only positioned neighbor", p.Lat, p.Lon) } + if p.ApproxNeighborCount != 1 { + t.Errorf("Points[0].ApproxNeighborCount = %d, want 1 (only AnchorRepeater is positioned)", p.ApproxNeighborCount) + } + if p.ApproxSpreadKm != nil { + t.Errorf("Points[0].ApproxSpreadKm = %v, want nil/omitted -- spread is meaningless with a single contributor", p.ApproxSpreadKm) + } if b.Observer == nil || b.Observer.Name != "Ghost Observer" { t.Fatalf("Observer = %+v, want Ghost Observer still named", b.Observer) @@ -826,6 +931,9 @@ func TestGetPacketPath_FallsBackToSingleNeighborPosition(t *testing.T) { if b.Observer.Lat == nil || *b.Observer.Lat != 55.5 || b.Observer.Lon == nil || *b.Observer.Lon != 9.5 { t.Errorf("Observer.Lat/Lon = %v/%v, want AnchorRepeater's exact position (55.5, 9.5)", b.Observer.Lat, b.Observer.Lon) } + if b.Observer.ApproxNeighborCount != 1 { + t.Errorf("Observer.ApproxNeighborCount = %d, want 1", b.Observer.ApproxNeighborCount) + } } // TestGetPacketPath_FallsBackToWeightedNeighborCentroid covers a hop @@ -877,6 +985,12 @@ func TestGetPacketPath_FallsBackToWeightedNeighborCentroid(t *testing.T) { if diff := *p.Lon - wantLon; diff > epsilon || diff < -epsilon { t.Errorf("Lon = %v, want weighted centroid %v", *p.Lon, wantLon) } + if p.ApproxNeighborCount != 2 { + t.Errorf("ApproxNeighborCount = %d, want 2 (AnchorRepeater + WeakRepeater)", p.ApproxNeighborCount) + } + if p.ApproxSpreadKm == nil || *p.ApproxSpreadKm < 100 { + t.Errorf("ApproxSpreadKm = %v, want a sizeable distance between AnchorRepeater (55.5,9.5) and WeakRepeater (60.0,15.0)", p.ApproxSpreadKm) + } } // TestGetPacketPath_ObserverPositionPrefersOwnGPS covers an observer whose @@ -916,6 +1030,9 @@ func TestGetPacketPath_ObserverPositionPrefersOwnGPS(t *testing.T) { if obs.Lat == nil || *obs.Lat != 56.19 || obs.Lon == nil || *obs.Lon != 9.6 { t.Errorf("Observer.Lat/Lon = %v/%v, want the node's own self-advertised GPS (56.19, 9.6), not left nil just because QXV isn't a known airport", obs.Lat, obs.Lon) } + if obs.Role != "room" { + t.Errorf("Observer.Role = %q, want room (from its own nodes row)", obs.Role) + } } // TestGetPacketPath_ObserverPositionFallsBackToNameMatch covers a diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 2cd89a95..44dfab65 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -337,33 +337,41 @@ func componentSchemas() map[string]interface{} { "type": "object", "description": "One hop's position along a packet's resolved relay path.", "properties": map[string]interface{}{ - "publicKey": str("Node public key (hex)."), - "name": str("Node display name, or its public key if unnamed."), - "role": str("Node role (e.g. repeater, room), when known."), - "lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position and has no positioned neighbor either."}, - "lon": map[string]interface{}{"type": "number", "nullable": true}, - "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this node's own position but a count-weighted centroid of its positioned neighbor_edges neighbors instead -- a last-resort stand-in, not a real fix."}, + "publicKey": str("Node public key (hex)."), + "name": str("Node display name, or its public key if unnamed."), + "role": str("Node role (e.g. repeater, room), when known."), + "lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position and has no positioned neighbor either."}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this node's own position but a count-weighted centroid of its positioned neighbor_edges neighbors instead -- a last-resort stand-in, not a real fix."}, + "approxNeighborCount": map[string]interface{}{"type": "integer", "description": "Present only when approx=true. How many positioned neighbors fed the centroid -- a rough confidence signal, higher is more confident."}, + "approxSpreadKm": map[string]interface{}{"type": "number", "nullable": true, "description": "Present only when approx=true and approxNeighborCount>1. Widest distance (km) between any two contributing neighbors -- larger means they disagree more about where 'nearby' is."}, }, }, "PacketPathObserver": map[string]interface{}{ "type": "object", "description": "The station that produced a given branch's observation of a packet path, positioned from its own self-advertised GPS when known (same source as /api/observers), else its configured IATA code, else a weighted centroid of its positioned neighbors (see approx).", "properties": map[string]interface{}{ - "name": str("Observer display name."), - "iata": str("Observer's configured IATA airport code, when set."), - "lat": map[string]interface{}{"type": "number", "nullable": true}, - "lon": map[string]interface{}{"type": "number", "nullable": true}, - "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this station's own position but a count-weighted centroid of its positioned neighbors instead -- a last-resort stand-in, not a real fix."}, + "publicKey": str("Observer's mesh pubkey, when it has one (some bridge-type observers publish under a device name instead -- see the name-match fallback in GetPacketPath). Empty otherwise."), + "name": str("Observer display name."), + "iata": str("Observer's configured IATA airport code, when set."), + "role": str("Observer's own node role (e.g. repeater, room), when it's known as a mesh node itself -- not just an MQTT/API listener."), + "lat": map[string]interface{}{"type": "number", "nullable": true}, + "lon": map[string]interface{}{"type": "number", "nullable": true}, + "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this station's own position but a count-weighted centroid of its positioned neighbors instead -- a last-resort stand-in, not a real fix."}, + "approxNeighborCount": map[string]interface{}{"type": "integer", "description": "Present only when approx=true. See PacketPathPoint.approxNeighborCount."}, + "approxSpreadKm": map[string]interface{}{"type": "number", "nullable": true, "description": "Present only when approx=true and approxNeighborCount>1. See PacketPathPoint.approxSpreadKm."}, }, }, "PacketPathBranch": 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."}, + "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 530fd346..a6fca65f 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -26,6 +26,36 @@ return v || '#888'; } + // Formats PacketPathBranch.secondsAfterFirst for a tooltip: how long + // after the earliest-arriving observation (the green landmark ring) + // this station's own observation arrived. + function formatElapsed(seconds) { + if (seconds === 0) return 'first to arrive'; + if (seconds < 60) return '+' + seconds.toFixed(1) + 's'; + var m = Math.floor(seconds / 60); + var s = Math.round(seconds % 60); + return '+' + m + 'm ' + s + 's'; + } + + // How much bigger/fuzzier an approximate marker's ring should be than + // a normal marker, given how many positioned neighbors fed the + // estimate (more = tighter) and how much they disagreed (a wide + // spread lowers confidence even with several contributors). + function approxRadiusBonus(count, spreadKm) { + var bonus; + if (!count || count <= 1) bonus = 6; + else if (count <= 3) bonus = 4; + else bonus = 2; + if (spreadKm != null && spreadKm > 100) bonus += 2; + return bonus; + } + + function approxFillOpacity(count) { + if (!count || count <= 1) return 0.12; + if (count <= 3) return 0.2; + return 0.3; + } + var activeMap = null; function onKeydown(e) { @@ -42,6 +72,20 @@ document.removeEventListener('keydown', onKeydown); } + // A short prefix marking a node's role in tooltips -- purely a label, + // markers stay circleMarker dots throughout (a role-specific shape + // would clash with the color/dash coding already carrying primary, + // approx, and observer meaning). + function roleIcon(role) { + switch (role) { + case 'repeater': return 'π‘ '; + case 'room': return 'π '; + case 'client': return 'π± '; + case 'sensor': return 'π‘οΈ '; + default: return ''; + } + } + // Turns one branch into a plottable chain: resolved hops with a known // position, then the observer's own position when known. A branch with // no locatable hops still contributes a single-point chain -- just the @@ -54,12 +98,21 @@ function chainForBranch(b) { var located = (b.points || []).filter(function (p) { return p.lat != null && p.lon != null; }); var chain = located.map(function (p, hi) { - return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (hi + 1) + ' of ' + b.hops, approx: !!p.approx }; + return { + lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (hi + 1) + ' of ' + b.hops, approx: !!p.approx, + approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, + publicKey: p.publicKey, + }; }); 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: b.hops + ' hop' + (b.hops === 1 ? '' : 's'), isObserver: true, approx: !!b.observer.approx, + label: observerLabel, isObserver: true, approx: !!b.observer.approx, + approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, + role: b.observer.role, publicKey: b.observer.publicKey, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -76,7 +129,7 @@ '' + '
How far and how wide this packet spread. The highlighted route is the farthest-traveled branch; every other station that heard it is shown too. The green ring marks whoever heard it first. Dashed markers are approximate -- estimated from nearby positioned neighbors, not the station\'s own position.
' + + 'How far and how wide this packet spread. The highlighted route is the farthest-traveled branch; every other station that heard it is shown too. The green ring marks whoever heard it first. Dashed markers are approximate -- estimated from nearby positioned neighbors, not the station\'s own position. Click a marker to open that node\'s detail page.
' + '' + '