mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 02:27:59 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
+119
-34
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-15
@@ -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{}{
|
||||
|
||||
@@ -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 @@
|
||||
'<button type="button" id="packetPathClose" aria-label="Close" ' +
|
||||
'style="position:absolute;top:8px;right:8px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;color:var(--text-muted)">×</button>' +
|
||||
'<h3 style="margin:0 0 4px;padding-right:24px">Relay Path</h3>' +
|
||||
'<p class="text-muted" style="margin:0 0 10px;font-size:12px">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.</p>' +
|
||||
'<p class="text-muted" style="margin:0 0 10px;font-size:12px">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.</p>' +
|
||||
'<div id="packetPathMapContainer" style="height:360px;border-radius:8px;overflow:hidden;background:var(--surface-1)"></div>' +
|
||||
'<div id="packetPathStatus" style="margin-top:8px;font-size:12px;color:var(--text-muted)">Loading…</div>' +
|
||||
'</div>';
|
||||
@@ -156,12 +209,31 @@
|
||||
// thick-dashed ring with a faint fill -- a plain hollow outline
|
||||
// at normal marker size was too easy to miss against map
|
||||
// tiles, so this deliberately reads as a bigger, softer blob
|
||||
// rather than a precise dot.
|
||||
? { radius: radius + 4, color: color, weight: 3, fillColor: color, fillOpacity: 0.2, dashArray: '5,4' }
|
||||
// rather than a precise dot. Size/fill scale with confidence:
|
||||
// more agreeing neighbors = tighter, more solid; one neighbor
|
||||
// or a wide spread among several = bigger, fainter.
|
||||
? {
|
||||
radius: radius + approxRadiusBonus(pt.approxNeighborCount, pt.approxSpreadKm), color: color, weight: 3,
|
||||
fillColor: color, fillOpacity: approxFillOpacity(pt.approxNeighborCount), dashArray: '5,4',
|
||||
}
|
||||
: { radius: radius, color: outline, weight: p.primary ? 2 : 1, fillColor: color, fillOpacity: p.primary ? 1 : 0.8 };
|
||||
L.circleMarker([pt.lat, pt.lon], markerOpts)
|
||||
var approxNote = '';
|
||||
if (pt.approx) {
|
||||
approxNote = ', approx. position';
|
||||
if (pt.approxNeighborCount) approxNote += ' from ' + pt.approxNeighborCount + ' neighbor' + (pt.approxNeighborCount === 1 ? '' : 's');
|
||||
}
|
||||
var clickNote = pt.publicKey ? ' — click for node detail' : '';
|
||||
var marker = L.circleMarker([pt.lat, pt.lon], markerOpts)
|
||||
.addTo(map)
|
||||
.bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + (pt.approx ? ', approx. position' : '') + ')');
|
||||
.bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + ')' + clickNote, { className: 'packet-path-tooltip' });
|
||||
if (pt.publicKey) {
|
||||
// Same #/nodes/{pubkey} hash route the rest of the app already
|
||||
// links to (see e.g. public/channels.js's node-detail links).
|
||||
marker.on('click', function () {
|
||||
close();
|
||||
window.location.hash = '#/nodes/' + encodeURIComponent(pt.publicKey);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (line.length > 1) {
|
||||
L.polyline(line, { color: lineColor, weight: p.primary ? 2.5 : 1.5, opacity: p.primary ? 0.85 : 0.5 }).addTo(map);
|
||||
@@ -182,7 +254,7 @@
|
||||
radius: 11, color: cssVar('--status-green'), weight: 3, fillOpacity: 0, opacity: 0.9,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindTooltip('🏁 First to hear it: ' + escapeHtml(firstPoint.name) + ' (' + data.first.hops + ' hop' + (data.first.hops === 1 ? '' : 's') + (firstPoint.approx ? ', approx. position' : '') + ')');
|
||||
.bindTooltip('🏁 First to hear it: ' + escapeHtml(firstPoint.name) + ' (' + data.first.hops + ' hop' + (data.first.hops === 1 ? '' : 's') + (firstPoint.approx ? ', approx. position' : '') + ')', { className: 'packet-path-tooltip' });
|
||||
}
|
||||
|
||||
try { map.fitBounds(bounds, { padding: [30, 30] }); } catch (e) { /* single point */ }
|
||||
|
||||
@@ -2644,6 +2644,23 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
.leaflet-popup-tip { background: var(--card-bg) !important; }
|
||||
.leaflet-popup-content { color: var(--text) !important; font-size: 13px !important; }
|
||||
|
||||
/* packet-path-map.js: tooltips got long once they started carrying role,
|
||||
approx/confidence, and distance/timing info together -- Leaflet's
|
||||
default tooltip is white-space:nowrap, which stretched a long one
|
||||
into an unreadable single line spanning the whole map. Wrap it onto
|
||||
multiple lines instead. Width is in ch (character-relative) rather
|
||||
than px so the box scales with font-size -- a fixed px width left
|
||||
room for barely one word per line at larger text sizes. The
|
||||
tooltip's containing pane has no intrinsic width, so a plain
|
||||
width:auto block collapses to min-content (one word per line,
|
||||
ignoring max-width entirely) -- width:max-content makes it size to
|
||||
its content up to max-width instead. */
|
||||
.leaflet-tooltip.packet-path-tooltip {
|
||||
white-space: normal;
|
||||
width: max-content;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
/* For Leaflet layer control */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-layers-expanded {
|
||||
|
||||
+192
-4
@@ -40,6 +40,13 @@ test('escapes node/observer names before interpolating into tooltip HTML (operat
|
||||
assert.ok(/escapeHtml\(pt\.name\)/.test(src), 'point tooltips must escape the name');
|
||||
});
|
||||
|
||||
test('tooltips use a wrapping CSS class -- Leaflet\'s default nowrap tooltip becomes unreadable once role/approx/distance/timing info are all combined', () => {
|
||||
const bindCalls = (src.match(/\.bindTooltip\(/g) || []).length;
|
||||
const classNameUses = (src.match(/className:\s*'packet-path-tooltip'/g) || []).length;
|
||||
assert.ok(bindCalls > 0, 'expected at least one bindTooltip call');
|
||||
assert.strictEqual(classNameUses, bindCalls, `expected every bindTooltip call (${bindCalls}) to pass the wrapping class, found ${classNameUses}`);
|
||||
});
|
||||
|
||||
test('draws every branch, not just the deepest one', () => {
|
||||
assert.ok(/branches\.map/.test(src), 'should iterate all branches from the response');
|
||||
});
|
||||
@@ -122,7 +129,10 @@ function makeSandbox(apiImpl) {
|
||||
const ctx = {
|
||||
window: {}, document: doc, console, Math, String, JSON, Promise, Error,
|
||||
setTimeout, clearTimeout,
|
||||
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
// Returns the variable name itself (not a real color) so tests can
|
||||
// assert two markers use DIFFERENT css vars without caring what the
|
||||
// actual theme color is.
|
||||
getComputedStyle: () => ({ getPropertyValue: (name) => name }),
|
||||
escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
api: apiImpl,
|
||||
L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths.
|
||||
@@ -214,7 +224,7 @@ function makeSandbox(apiImpl) {
|
||||
remove() {},
|
||||
}),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; } }; },
|
||||
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }; },
|
||||
polyline: () => { polylineCount++; return { addTo() { return this; } }; },
|
||||
};
|
||||
|
||||
@@ -252,7 +262,7 @@ function makeSandbox(apiImpl) {
|
||||
remove() {},
|
||||
}),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; } }; },
|
||||
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }; },
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
@@ -295,7 +305,7 @@ function makeSandbox(apiImpl) {
|
||||
circleMarker: (latlng, opts) => {
|
||||
if (opts && opts.dashArray) approxMarkerCalls++;
|
||||
else solidMarkerCalls++;
|
||||
return { addTo() { return this; }, bindTooltip() { return this; } };
|
||||
return { addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } };
|
||||
},
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
@@ -310,6 +320,184 @@ function makeSandbox(apiImpl) {
|
||||
} catch (e) { failed++; console.log(' ❌ approximate (neighbor-borrowed) positions render hollow/dashed and are called out in status: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// branch.secondsAfterFirst (0 for the earliest arrival, positive
|
||||
// for later ones) should show up in the observer's tooltip label.
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{ hops: 2, points: [], observer: { name: 'LateObserver', lat: 56.0, lon: 10.0 }, secondsAfterFirst: 4.7 },
|
||||
],
|
||||
first: { hops: 0, points: [], observer: { name: 'LateObserver', lat: 56.0, lon: 10.0 }, secondsAfterFirst: 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; }, on() { return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
assert.ok(tooltips.some((t) => t.includes('+4.7s')), 'expected a tooltip with the +4.7s elapsed time, got: ' + JSON.stringify(tooltips));
|
||||
passed++;
|
||||
console.log(' ✅ secondsAfterFirst renders as an elapsed-time label in the tooltip');
|
||||
} 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; }, on() { 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,
|
||||
// fainter ring than a 4-neighbor approx point -- more agreeing
|
||||
// neighbors means more confidence, so a tighter, more solid marker.
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{
|
||||
hops: 2,
|
||||
points: [
|
||||
{ publicKey: 'pk1', name: 'LowConfidence', lat: 56.0, lon: 10.0, approx: true, approxNeighborCount: 1 },
|
||||
{ publicKey: 'pk2', name: 'HighConfidence', lat: 56.1, lon: 10.1, approx: true, approxNeighborCount: 4, approxSpreadKm: 5 },
|
||||
],
|
||||
observer: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const markerOptsByName = {};
|
||||
const tooltipByCall = [];
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: (latlng, opts) => {
|
||||
tooltipByCall.push(opts);
|
||||
return { addTo() { return this; }, bindTooltip(t) { markerOptsByName[t] = opts; return this; }, on() { return this; } };
|
||||
},
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const lowKey = Object.keys(markerOptsByName).find((k) => k.includes('LowConfidence'));
|
||||
const highKey = Object.keys(markerOptsByName).find((k) => k.includes('HighConfidence'));
|
||||
assert.ok(lowKey, 'expected a tooltip for LowConfidence');
|
||||
assert.ok(highKey, 'expected a tooltip for HighConfidence');
|
||||
assert.ok(markerOptsByName[lowKey].radius > markerOptsByName[highKey].radius,
|
||||
'expected the 1-neighbor marker to be larger than the 4-neighbor marker, got radii ' + markerOptsByName[lowKey].radius + ' vs ' + markerOptsByName[highKey].radius);
|
||||
assert.ok(markerOptsByName[lowKey].fillOpacity < markerOptsByName[highKey].fillOpacity,
|
||||
'expected the 1-neighbor marker to be fainter than the 4-neighbor marker');
|
||||
assert.ok(lowKey.includes('from 1 neighbor'), 'expected the tooltip to mention the neighbor count, got: ' + lowKey);
|
||||
assert.ok(highKey.includes('from 4 neighbors'), 'expected the tooltip to mention the neighbor count, got: ' + highKey);
|
||||
passed++;
|
||||
console.log(' ✅ approximate markers scale size/opacity by neighbor confidence');
|
||||
} catch (e) { failed++; console.log(' ❌ approximate markers scale size/opacity by neighbor confidence: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// A hop point and an observer with a known `role` should get a
|
||||
// role-specific icon prefix in their tooltip.
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{
|
||||
hops: 1,
|
||||
points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: 56.0, lon: 10.0, role: 'repeater' }],
|
||||
observer: { name: 'RoomObserver', lat: 56.1, lon: 10.1, role: 'room' },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const 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; }, on() { return this; } }),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
assert.ok(tooltips.some((t) => t.includes('📡') && t.includes('RepeaterA')), 'expected a repeater icon on RepeaterA, got: ' + JSON.stringify(tooltips));
|
||||
assert.ok(tooltips.some((t) => t.includes('🏠') && t.includes('RoomObserver')), 'expected a room icon on RoomObserver, got: ' + JSON.stringify(tooltips));
|
||||
passed++;
|
||||
console.log(' ✅ nodes with a known role get a role icon in their tooltip');
|
||||
} catch (e) { failed++; console.log(' ❌ nodes with a known role get a role icon in their tooltip: ' + e.message); }
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
try {
|
||||
// A marker with a publicKey should register a click handler that
|
||||
// navigates to #/nodes/{pubkey} (closing the modal first); one
|
||||
// without a publicKey should register no click handler at all.
|
||||
const ctx = makeSandbox(() => Promise.resolve({
|
||||
hash: 'deadbeef',
|
||||
branches: [
|
||||
{
|
||||
hops: 1,
|
||||
points: [{ publicKey: 'pk-with-key', name: 'HasKey', lat: 56.0, lon: 10.0 }],
|
||||
observer: { name: 'NoKeyObserver', lat: 56.1, lon: 10.1 }, // no publicKey
|
||||
},
|
||||
],
|
||||
}));
|
||||
ctx.window.location = { hash: '' };
|
||||
|
||||
const clickHandlersByTooltip = {};
|
||||
let lastTooltip = null;
|
||||
ctx.L = {
|
||||
map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }),
|
||||
tileLayer: () => ({ addTo() { return this; } }),
|
||||
circleMarker: () => ({
|
||||
addTo() { return this; },
|
||||
bindTooltip(t) { lastTooltip = t; return this; },
|
||||
on(evt, fn) { if (evt === 'click') clickHandlersByTooltip[lastTooltip] = fn; return this; },
|
||||
}),
|
||||
polyline: () => ({ addTo() { return this; } }),
|
||||
};
|
||||
|
||||
await ctx.window.PacketPathMap.open('deadbeef');
|
||||
const hasKeyTooltip = Object.keys(clickHandlersByTooltip).find((t) => t.includes('HasKey'));
|
||||
assert.ok(hasKeyTooltip, 'expected a click handler registered for the HasKey marker, got: ' + JSON.stringify(Object.keys(clickHandlersByTooltip)));
|
||||
assert.ok(hasKeyTooltip.includes('click for node detail'), 'expected the tooltip to hint it is clickable, got: ' + hasKeyTooltip);
|
||||
assert.ok(!Object.keys(clickHandlersByTooltip).some((t) => t.includes('NoKeyObserver')), 'expected NO click handler for the keyless observer');
|
||||
|
||||
clickHandlersByTooltip[hasKeyTooltip]();
|
||||
assert.strictEqual(ctx.window.location.hash, '#/nodes/pk-with-key', 'expected clicking the marker to navigate to the node detail hash route, got: ' + ctx.window.location.hash);
|
||||
assert.ok(!ctx.document.getElementById('packetPathModal'), 'expected the modal to close after navigating away');
|
||||
passed++;
|
||||
console.log(' ✅ markers with a publicKey are clickable and navigate to node detail, closing the modal');
|
||||
} catch (e) { failed++; console.log(' ❌ markers with a publicKey are clickable and navigate to node detail, closing the modal: ' + e.message); }
|
||||
})();
|
||||
|
||||
console.log('\n════════════════════════════════════════');
|
||||
console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`);
|
||||
console.log('════════════════════════════════════════');
|
||||
|
||||
Reference in New Issue
Block a user