From 8e61a78d32978fa37e9b24d9f3cb73530a24c587 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 17:38:15 +0200 Subject: [PATCH 01/10] feat: View Path shows how long after 'first' each station heard the packet Adds SecondsAfterFirst to PacketPathBranch: the gap between the earliest-arriving observation (First) and this branch's own deepest observation. Zero for First itself. Rendered in the observer's tooltip as "+4.7s" (or "Nm Ss" for longer gaps, "first to arrive" for zero) -- gives a sense of the propagation order across the flood, not just depth/breadth. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 16 +++++++++++++++- cmd/server/db_test.go | 23 +++++++++++++++++++++++ cmd/server/openapi.go | 9 +++++---- public/packet-path-map.js | 15 ++++++++++++++- test-packet-path-map.js | 27 +++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index da517d50..2ac784b6 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1559,6 +1559,11 @@ 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"` } // PacketPathResponse is every branch a packet is known to have reached -- @@ -1616,6 +1621,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 +1653,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 @@ -1864,6 +1874,10 @@ 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 } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index fd520685..07ef91a9 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -719,6 +719,29 @@ 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) + } } // TestGetPacketPath_ExcludesNullIsland covers a node whose nodes.lat/lon diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 2cd89a95..a8d433f0 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -360,10 +360,11 @@ 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."}, + "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."}, }, }, "PacketPathResponse": map[string]interface{}{ diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 530fd346..a571a7fd 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -26,6 +26,17 @@ 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'; + } + var activeMap = null; function onKeydown(e) { @@ -57,9 +68,11 @@ return { lat: p.lat, lon: p.lon, name: p.name, label: 'hop ' + (hi + 1) + ' of ' + b.hops, approx: !!p.approx }; }); 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); 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, }); } return { chain: chain, missing: (b.points || []).length - located.length }; diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 121e276d..cbd2c1c0 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -310,6 +310,33 @@ 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; } }), + 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); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════'); From 4ecb9c39027bc91842636b89d69e0b514189b92b Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 17:44:19 +0200 Subject: [PATCH 02/10] feat: scale View Path approximate markers by neighbor confidence nearestPositionedNeighbor now also returns how many positioned neighbors fed the weighted centroid and the widest distance between any two of them (0 with a single contributor). Exposed via new ApproxNeighborCount/ApproxSpreadKm fields on PacketPathPoint and PacketPathObserver. The map scales the approximate marker's size/opacity accordingly: one neighbor (or several that disagree widely) renders as a bigger, fainter ring; several agreeing neighbors render tighter and more solid. The tooltip also states the contributor count. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 85 ++++++++++++++++++++++++++++----------- cmd/server/db_test.go | 15 +++++++ cmd/server/openapi.go | 26 +++++++----- public/packet-path-map.js | 41 +++++++++++++++++-- test-packet-path-map.js | 47 ++++++++++++++++++++++ 5 files changed, 176 insertions(+), 38 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 2ac784b6..b8148273 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 @@ -1544,10 +1553,12 @@ type PacketPathObserver struct { 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"` + // 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 @@ -1824,11 +1835,16 @@ 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) @@ -1861,11 +1877,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 @@ -1906,12 +1926,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 @@ -1920,7 +1948,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 @@ -1936,7 +1964,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) @@ -1972,6 +2000,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 { @@ -1987,11 +2016,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 07ef91a9..a0822835 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -839,6 +839,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) @@ -849,6 +855,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 @@ -900,6 +909,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 diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index a8d433f0..83cb0eb7 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -337,23 +337,27 @@ 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."}, + "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."}, + "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{}{ diff --git a/public/packet-path-map.js b/public/packet-path-map.js index a571a7fd..0ea3cd44 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -37,6 +37,25 @@ 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) { @@ -65,7 +84,10 @@ 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, + }; }); if (b.observer && b.observer.lat != null && b.observer.lon != null) { var observerLabel = b.hops + ' hop' + (b.hops === 1 ? '' : 's'); @@ -73,6 +95,7 @@ chain.push({ lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name, label: observerLabel, isObserver: true, approx: !!b.observer.approx, + approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -169,12 +192,22 @@ // 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 }; + var approxNote = ''; + if (pt.approx) { + approxNote = ', approx. position'; + if (pt.approxNeighborCount) approxNote += ' from ' + pt.approxNeighborCount + ' neighbor' + (pt.approxNeighborCount === 1 ? '' : 's'); + } L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + (pt.approx ? ', approx. position' : '') + ')'); + .bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + approxNote + ')'); }); 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); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index cbd2c1c0..fe6ccbb5 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -337,6 +337,53 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ secondsAfterFirst renders as an elapsed-time 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; } }; + }, + 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); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════'); From d5214006d6d2584365803b4b9619ae2d3e02e901 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 18:03:25 +0200 Subject: [PATCH 03/10] feat: View Path shows a role icon for observers with a known node role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PacketPathPoint already carried Role; PacketPathObserver now does too (from the observer's own nodes row, when it's known as a mesh node itself and not just an MQTT/API listener). Rendered as a small icon prefix in the tooltip (📡 repeater, 🏠 room, 📱 client, 🌡️ sensor) -- markers stay plain circleMarker dots throughout, since a role-specific shape would clash with the color/dash coding already carrying primary, approx, and observer meaning. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 6 ++++++ cmd/server/db_test.go | 3 +++ cmd/server/openapi.go | 1 + public/packet-path-map.js | 19 +++++++++++++++++-- test-packet-path-map.js | 31 +++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index b8148273..d619b88f 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1551,6 +1551,7 @@ type PacketPathPoint struct { type PacketPathObserver struct { 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 @@ -1865,6 +1866,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 { diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index a0822835..985401ad 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -954,6 +954,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 83cb0eb7..29e6b3fb 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -353,6 +353,7 @@ func componentSchemas() map[string]interface{} { "properties": map[string]interface{}{ "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."}, diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 0ea3cd44..5f78efe9 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -72,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 @@ -86,7 +100,7 @@ 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, - approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, + approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, }; }); if (b.observer && b.observer.lat != null && b.observer.lon != null) { @@ -96,6 +110,7 @@ lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name, label: observerLabel, isObserver: true, approx: !!b.observer.approx, approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, + role: b.observer.role, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -207,7 +222,7 @@ } L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(escapeHtml(pt.name) + ' (' + pt.label + approxNote + ')'); + .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + ')'); }); 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); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index fe6ccbb5..ba332837 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -384,6 +384,37 @@ function makeSandbox(apiImpl) { } 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; } }), + 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); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════'); From 575743efdaea0573feecab36a72e9ad067f6f32b Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 18:16:15 +0200 Subject: [PATCH 04/10] feat: View Path highlights confirmed bridge repeaters Adds IsBridge to PacketPathPoint/PacketPathObserver and PublicKey to PacketPathObserver (needed to look bridges up, and useful on its own for future features). Set by a new markBridgeRepeaters handler-level step in routes.go, not GetPacketPath itself, since the underlying data (TransportedScopes per repeater) lives in the in-memory store, not SQL -- same "relays for 2+ distinct regions" definition and data source as the Foreign Traffic tab's existing Bridge badge (ScopeStatsResponse.bridgeRepeaters), just applied to whichever handful of pubkeys one packet's path touches. The map gives a bridge repeater a bold purple outline (on top of whatever primary/approx styling already applies) plus a tooltip note, so the mesh's backbone nodes stand out regardless of which branch they're in. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 27 +++++++--- cmd/server/db_test.go | 3 ++ cmd/server/openapi.go | 3 ++ cmd/server/packet_path_bridge_test.go | 76 +++++++++++++++++++++++++++ cmd/server/routes.go | 37 +++++++++++++ public/packet-path-map.js | 17 ++++-- test-packet-path-map.js | 45 +++++++++++++++- 7 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 cmd/server/packet_path_bridge_test.go diff --git a/cmd/server/db.go b/cmd/server/db.go index d619b88f..83d08dc2 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1540,6 +1540,13 @@ type PacketPathPoint struct { // those neighbors -- 0 (and omitted) with a single contributor; // larger means the neighbors disagree more about where "nearby" is. ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` + // IsBridge is true when this node has been confirmed relaying + // traffic for 2+ distinct region scopes -- the same "Bridge" badge + // definition as the Foreign Traffic tab (ScopeStatsResponse. + // bridgeRepeaters). Set by the handler (routes.go), not GetPacketPath + // itself, since the underlying data lives in the in-memory store, not + // SQL. Absent/false when the store isn't available. + IsBridge bool `json:"isBridge,omitempty"` } // PacketPathObserver is the station that produced a given branch's @@ -1549,17 +1556,25 @@ 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"` - Role string `json:"role,omitempty"` - Lat *float64 `json:"lat"` - Lon *float64 `json:"lon"` + // 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 or cross-reference other per-node data + // (e.g. IsBridge, filled in by the handler). 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"` + // IsBridge -- see PacketPathPoint.IsBridge. + IsBridge bool `json:"isBridge,omitempty"` } // PacketPathBranch is one station's route to a packet: how far it @@ -1851,7 +1866,7 @@ func (db *DB) GetPacketPath(hash string) (*PacketPathResponse, error) { 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)) } diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 985401ad..16461c2e 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) } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 29e6b3fb..2722657b 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -345,12 +345,14 @@ func componentSchemas() map[string]interface{} { "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."}, + "isBridge": map[string]interface{}{"type": "boolean", "description": "True when this node has been confirmed relaying traffic for 2+ distinct region scopes -- same definition/data source as the Foreign Traffic tab's Bridge badge. Set by the handler from the in-memory store; always false when that store isn't available."}, }, }, "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{}{ + "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."), @@ -359,6 +361,7 @@ func componentSchemas() map[string]interface{} { "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."}, + "isBridge": map[string]interface{}{"type": "boolean", "description": "See PacketPathPoint.isBridge."}, }, }, "PacketPathBranch": map[string]interface{}{ diff --git a/cmd/server/packet_path_bridge_test.go b/cmd/server/packet_path_bridge_test.go new file mode 100644 index 00000000..05a128c8 --- /dev/null +++ b/cmd/server/packet_path_bridge_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "testing" + "time" +) + +// TestMarkBridgeRepeaters_NilStore covers the graceful no-op when the +// in-memory store isn't available (s.store can legitimately be nil -- +// see handlePacketPath). Must not panic and must leave every IsBridge +// at its zero value (false). +func TestMarkBridgeRepeaters_NilStore(t *testing.T) { + resp := &PacketPathResponse{ + Hash: "deadbeef", + Branches: []PacketPathBranch{ + {Points: []PacketPathPoint{{PublicKey: "pk1"}}, Observer: &PacketPathObserver{PublicKey: "obs1"}}, + }, + } + markBridgeRepeaters(resp, nil, &Config{}) + if resp.Branches[0].Points[0].IsBridge { + t.Errorf("Points[0].IsBridge = true, want false -- store is nil, nothing should be marked") + } + if resp.Branches[0].Observer.IsBridge { + t.Errorf("Observer.IsBridge = true, want false -- store is nil, nothing should be marked") + } +} + +// TestMarkBridgeRepeaters_TwoScopesIsBridge covers the actual +// determination: a pubkey relaying 2+ distinct region scopes (the same +// definition/data source as the Foreign Traffic tab's "Bridge" badge, +// ScopeStatsResponse.bridgeRepeaters) gets IsBridge=true on every point +// and observer entry referencing it; a pubkey with 0 or 1 scope, or one +// missing from the relay map entirely, stays false. +func TestMarkBridgeRepeaters_TwoScopesIsBridge(t *testing.T) { + store := &PacketStore{ + repeaterRelayCache: map[string]RepeaterRelayInfo{ + "bridgepk": {TransportedScopes: []string{"#dk", "#se"}}, + "regionalpk": {TransportedScopes: []string{"#dk"}}, + "unknownscopepk": {TransportedScopes: nil}, + }, + repeaterRelayCacheWin: 24, + repeaterRelayAt: time.Now(), + } + resp := &PacketPathResponse{ + Hash: "deadbeef", + Branches: []PacketPathBranch{ + { + Points: []PacketPathPoint{ + {PublicKey: "bridgepk"}, + {PublicKey: "regionalpk"}, + {PublicKey: "unknownscopepk"}, + {PublicKey: "notinrelaymap"}, + }, + Observer: &PacketPathObserver{PublicKey: "BridgePK"}, // case-insensitive match + }, + }, + } + markBridgeRepeaters(resp, store, &Config{}) + + pts := resp.Branches[0].Points + if !pts[0].IsBridge { + t.Errorf("Points[0] (bridgepk, 2 scopes) IsBridge = false, want true") + } + if pts[1].IsBridge { + t.Errorf("Points[1] (regionalpk, 1 scope) IsBridge = true, want false") + } + if pts[2].IsBridge { + t.Errorf("Points[2] (unknownscopepk, 0 scopes) IsBridge = true, want false") + } + if pts[3].IsBridge { + t.Errorf("Points[3] (notinrelaymap, absent from relay map) IsBridge = true, want false") + } + if !resp.Branches[0].Observer.IsBridge { + t.Errorf("Observer (BridgePK, case-insensitive match on bridgepk) IsBridge = false, want true") + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index d88fca6c..2e42da72 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3164,9 +3164,46 @@ func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } + markBridgeRepeaters(resp, s.store, s.cfg) writeJSON(w, resp) } +// markBridgeRepeaters sets IsBridge on every point/observer in resp whose +// pubkey has been confirmed relaying traffic for 2+ distinct region +// scopes -- the same "Bridge" definition/data source as the Foreign +// Traffic tab's badge (ScopeStatsResponse.bridgeRepeaters), just applied +// to whichever handful of pubkeys this one packet's path touches instead +// of scanning the whole mesh. No-op when the in-memory store isn't +// available (IsBridge stays false/omitted on every entry). +func markBridgeRepeaters(resp *PacketPathResponse, store *PacketStore, cfg *Config) { + if resp == nil || store == nil || cfg == nil { + return + } + relayMap := store.GetRepeaterRelayInfoMap(cfg.GetHealthThresholds().RelayActiveHours) + isBridge := func(pubkey string) bool { + if pubkey == "" { + return false + } + info, ok := relayMap[strings.ToLower(pubkey)] + return ok && len(info.TransportedScopes) >= 2 + } + mark := func(b *PacketPathBranch) { + if b == nil { + return + } + for i := range b.Points { + b.Points[i].IsBridge = isBridge(b.Points[i].PublicKey) + } + if b.Observer != nil { + b.Observer.IsBridge = isBridge(b.Observer.PublicKey) + } + } + for i := range resp.Branches { + mark(&resp.Branches[i]) + } + mark(resp.First) +} + var iataCoords = map[string]IataCoord{ "SJC": {Lat: 37.3626, Lon: -121.929}, "SFO": {Lat: 37.6213, Lon: -122.379}, diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 5f78efe9..223adf51 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -100,7 +100,7 @@ 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, - approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, + approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, isBridge: !!p.isBridge, }; }); if (b.observer && b.observer.lat != null && b.observer.lon != null) { @@ -110,7 +110,7 @@ lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name, label: observerLabel, isObserver: true, approx: !!b.observer.approx, approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, - role: b.observer.role, + role: b.observer.role, isBridge: !!b.observer.isBridge, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -127,7 +127,7 @@ '' + '

Relay Path

' + - '

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. A bold purple outline marks a confirmed bridge repeater.

' + '
' + '
Loading…
' + ''; @@ -215,14 +215,23 @@ 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 }; + if (pt.isBridge) { + // Bridge repeaters (confirmed relaying for 2+ regions -- same + // definition as the Foreign Traffic tab's badge) get a bold + // purple outline on top of whatever primary/approx styling + // already applies, so they stand out as the mesh's backbone + // nodes regardless of which branch they're in. + markerOpts = Object.assign({}, markerOpts, { color: cssVar('--status-purple'), weight: markerOpts.weight + 1 }); + } var approxNote = ''; if (pt.approx) { approxNote = ', approx. position'; if (pt.approxNeighborCount) approxNote += ' from ' + pt.approxNeighborCount + ' neighbor' + (pt.approxNeighborCount === 1 ? '' : 's'); } + var bridgeNote = pt.isBridge ? ', bridge repeater' : ''; L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + ')'); + .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')'); }); 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); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index ba332837..0ceacd58 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -122,7 +122,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, '>'), api: apiImpl, L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths. @@ -415,6 +418,46 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ nodes with a known role get a role icon in their tooltip: ' + e.message); } })(); + await (async () => { + try { + // isBridge=true should get a bold purple outline (overriding the + // normal stroke color/weight) and a "bridge repeater" tooltip note. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 1, + points: [ + { publicKey: 'pk1', name: 'PlainRepeater', lat: 56.0, lon: 10.0, isBridge: false }, + { publicKey: 'pk2', name: 'BridgeRepeater', lat: 56.1, lon: 10.1, isBridge: true }, + ], + observer: null, + }, + ], + })); + + const optsByTooltip = {}; + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: (latlng, opts) => ({ addTo() { return this; }, bindTooltip(t) { optsByTooltip[t] = opts; return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const plainKey = Object.keys(optsByTooltip).find((k) => k.includes('PlainRepeater')); + const bridgeKey = Object.keys(optsByTooltip).find((k) => k.includes('BridgeRepeater')); + assert.ok(plainKey, 'expected a tooltip for PlainRepeater'); + assert.ok(bridgeKey, 'expected a tooltip for BridgeRepeater'); + assert.ok(!plainKey.includes('bridge repeater'), 'PlainRepeater tooltip should not mention bridge, got: ' + plainKey); + assert.ok(bridgeKey.includes('bridge repeater'), 'BridgeRepeater tooltip should mention bridge, got: ' + bridgeKey); + assert.notStrictEqual(optsByTooltip[bridgeKey].color, optsByTooltip[plainKey].color, 'expected the bridge marker to use a distinct outline color'); + assert.ok(optsByTooltip[bridgeKey].weight > optsByTooltip[plainKey].weight, 'expected the bridge marker outline to be thicker'); + passed++; + console.log(' ✅ bridge repeaters get a distinct outline and tooltip note'); + } catch (e) { failed++; console.log(' ❌ bridge repeaters get a distinct outline and tooltip note: ' + e.message); } + })(); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════'); From 3c8ec35ea22d43c1188a0942dda8c69ff4f76ed1 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 18:23:05 +0200 Subject: [PATCH 05/10] feat: View Path shows distance from 'first' in km Adds DistanceFromFirstKm to PacketPathBranch: the great-circle distance (haversine) between a branch's own Observer and First's Observer. Zero for First itself. Deliberately omitted when either side is positioned via Approx (a neighbor-centroid estimate) -- a distance computed against a guess isn't a real measurement worth surfacing. Rendered in the observer tooltip as "42.3 km away" alongside the elapsed-time label, giving a concrete sense of how far the flood physically reached, not just how many hops or how long it took. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 31 ++++++++++++++--- cmd/server/db_test.go | 73 +++++++++++++++++++++++++++++++++++++++ cmd/server/openapi.go | 11 +++--- public/packet-path-map.js | 1 + test-packet-path-map.js | 29 ++++++++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) 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, From d0205b0edd471a858b36fba59a0b292178d9adbf Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 18:34:50 +0200 Subject: [PATCH 06/10] feat: View Path markers are clickable, navigate to node detail Hop points already carried publicKey; PacketPathObserver got it in the bridge-highlighting commit. Clicking any marker with one now closes the modal and navigates to #/nodes/{pubkey} -- the same hash route the rest of the app already links to (see e.g. public/channels.js). A marker with no publicKey (e.g. a bridge-type observer keyed by device name, not pubkey) gets no click handler and stays inert. Tooltip gets a "click for node detail" hint when applicable. Co-Authored-By: Claude Sonnet 5 --- public/packet-path-map.js | 18 +++++++++--- test-packet-path-map.js | 60 +++++++++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 7b6ae3c5..3be7c511 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -101,6 +101,7 @@ 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, isBridge: !!p.isBridge, + publicKey: p.publicKey, }; }); if (b.observer && b.observer.lat != null && b.observer.lon != null) { @@ -111,7 +112,7 @@ lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name, label: observerLabel, isObserver: true, approx: !!b.observer.approx, approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, - role: b.observer.role, isBridge: !!b.observer.isBridge, + role: b.observer.role, isBridge: !!b.observer.isBridge, publicKey: b.observer.publicKey, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -128,7 +129,7 @@ '' + '

Relay Path

' + - '

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. A bold purple outline marks a confirmed bridge repeater.

' + + '

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. A bold purple outline marks a confirmed bridge repeater. Click a marker to open that node\'s detail page.

' + '
' + '
Loading…
' + ''; @@ -230,9 +231,18 @@ if (pt.approxNeighborCount) approxNote += ' from ' + pt.approxNeighborCount + ' neighbor' + (pt.approxNeighborCount === 1 ? '' : 's'); } var bridgeNote = pt.isBridge ? ', bridge repeater' : ''; - L.circleMarker([pt.lat, pt.lon], markerOpts) + var clickNote = pt.publicKey ? ' — click for node detail' : ''; + var marker = L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')'); + .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')' + clickNote); + 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); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 5fd55f04..d580fe75 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -217,7 +217,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; } }; }, }; @@ -255,7 +255,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; } }), }; @@ -298,7 +298,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; } }), }; @@ -329,7 +329,7 @@ function makeSandbox(apiImpl) { ctx.L = { map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), tileLayer: () => ({ addTo() { return this; } }), - circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; }, on() { return this; } }), polyline: () => ({ addTo() { return this; } }), }; @@ -357,7 +357,7 @@ function makeSandbox(apiImpl) { ctx.L = { map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), tileLayer: () => ({ addTo() { return this; } }), - circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; }, on() { return this; } }), polyline: () => ({ addTo() { return this; } }), }; @@ -395,7 +395,7 @@ function makeSandbox(apiImpl) { tileLayer: () => ({ addTo() { return this; } }), circleMarker: (latlng, opts) => { tooltipByCall.push(opts); - return { addTo() { return this; }, bindTooltip(t) { markerOptsByName[t] = opts; return this; } }; + return { addTo() { return this; }, bindTooltip(t) { markerOptsByName[t] = opts; return this; }, on() { return this; } }; }, polyline: () => ({ addTo() { return this; } }), }; @@ -435,7 +435,7 @@ function makeSandbox(apiImpl) { ctx.L = { map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), tileLayer: () => ({ addTo() { return this; } }), - circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip(t) { tooltips.push(t); return this; }, on() { return this; } }), polyline: () => ({ addTo() { return this; } }), }; @@ -469,7 +469,7 @@ function makeSandbox(apiImpl) { ctx.L = { map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), tileLayer: () => ({ addTo() { return this; } }), - circleMarker: (latlng, opts) => ({ addTo() { return this; }, bindTooltip(t) { optsByTooltip[t] = opts; return this; } }), + circleMarker: (latlng, opts) => ({ addTo() { return this; }, bindTooltip(t) { optsByTooltip[t] = opts; return this; }, on() { return this; } }), polyline: () => ({ addTo() { return this; } }), }; @@ -487,6 +487,50 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ bridge repeaters get a distinct outline and tooltip note: ' + 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('════════════════════════════════════════'); From 6718c246b9f33bd4bdefbb8a048b885aa11040e8 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 19:19:57 +0200 Subject: [PATCH 07/10] fix: View Path tooltips wrap instead of stretching across the whole map Tooltip text grew long once role, approx/confidence, bridge, and distance/timing info were all combined into one line. Leaflet's default tooltip is white-space:nowrap, so a long one stretched into a single unreadable line spanning the map instead of wrapping. Adds a packet-path-tooltip CSS class (white-space:normal, max-width) passed to every bindTooltip call in this component. Co-Authored-By: Claude Sonnet 5 --- public/packet-path-map.js | 4 ++-- public/style.css | 10 ++++++++++ test-packet-path-map.js | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 3be7c511..b73e0923 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -234,7 +234,7 @@ var clickNote = pt.publicKey ? ' — click for node detail' : ''; var marker = L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')' + clickNote); + .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')' + 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). @@ -263,7 +263,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 */ } diff --git a/public/style.css b/public/style.css index 59bcbd20..1a300c56 100644 --- a/public/style.css +++ b/public/style.css @@ -2644,6 +2644,16 @@ 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, bridge, 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 within a fixed width instead. */ +.leaflet-tooltip.packet-path-tooltip { + white-space: normal; + max-width: 220px; +} + /* For Leaflet layer control */ .leaflet-control-layers, .leaflet-control-layers-expanded { diff --git a/test-packet-path-map.js b/test-packet-path-map.js index d580fe75..d3e4c53f 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -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/bridge/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'); }); From 4b4dc9f27f4e61072eb88bfb65bcb76f39a5c266 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 19:27:56 +0200 Subject: [PATCH 08/10] revert: remove View Path bridge-repeater highlighting The "relays for 2+ distinct region scopes" definition doesn't hold up in practice: most nodes end up carrying both a broad national/regional scope (e.g. #dk, #eu) and several local ones, so the vast majority of repeaters would eventually qualify as "bridge" -- the flag stops meaning anything useful. Removes IsBridge from PacketPathPoint/PacketPathObserver, the markBridgeRepeaters handler step, the purple-outline styling, and the dedicated test file -- keeps PublicKey on PacketPathObserver, since the click-to-node-detail feature depends on it independently. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 14 +---- cmd/server/openapi.go | 2 - cmd/server/packet_path_bridge_test.go | 76 --------------------------- cmd/server/routes.go | 37 ------------- public/packet-path-map.js | 17 ++---- public/style.css | 8 +-- test-packet-path-map.js | 42 +-------------- 7 files changed, 11 insertions(+), 185 deletions(-) delete mode 100644 cmd/server/packet_path_bridge_test.go diff --git a/cmd/server/db.go b/cmd/server/db.go index 85b07f85..2862722c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -1540,13 +1540,6 @@ type PacketPathPoint struct { // those neighbors -- 0 (and omitted) with a single contributor; // larger means the neighbors disagree more about where "nearby" is. ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` - // IsBridge is true when this node has been confirmed relaying - // traffic for 2+ distinct region scopes -- the same "Bridge" badge - // definition as the Foreign Traffic tab (ScopeStatsResponse. - // bridgeRepeaters). Set by the handler (routes.go), not GetPacketPath - // itself, since the underlying data lives in the in-memory store, not - // SQL. Absent/false when the store isn't available. - IsBridge bool `json:"isBridge,omitempty"` } // PacketPathObserver is the station that produced a given branch's @@ -1558,9 +1551,8 @@ type PacketPathPoint struct { type PacketPathObserver struct { // 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 or cross-reference other per-node data - // (e.g. IsBridge, filled in by the handler). Empty for an observer - // whose id never resembled a pubkey. + // 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"` @@ -1573,8 +1565,6 @@ type PacketPathObserver struct { Approx bool `json:"approx,omitempty"` ApproxNeighborCount int `json:"approxNeighborCount,omitempty"` ApproxSpreadKm *float64 `json:"approxSpreadKm,omitempty"` - // IsBridge -- see PacketPathPoint.IsBridge. - IsBridge bool `json:"isBridge,omitempty"` } // PacketPathBranch is one station's route to a packet: how far it diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 37642972..44dfab65 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -345,7 +345,6 @@ func componentSchemas() map[string]interface{} { "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."}, - "isBridge": map[string]interface{}{"type": "boolean", "description": "True when this node has been confirmed relaying traffic for 2+ distinct region scopes -- same definition/data source as the Foreign Traffic tab's Bridge badge. Set by the handler from the in-memory store; always false when that store isn't available."}, }, }, "PacketPathObserver": map[string]interface{}{ @@ -361,7 +360,6 @@ func componentSchemas() map[string]interface{} { "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."}, - "isBridge": map[string]interface{}{"type": "boolean", "description": "See PacketPathPoint.isBridge."}, }, }, "PacketPathBranch": map[string]interface{}{ diff --git a/cmd/server/packet_path_bridge_test.go b/cmd/server/packet_path_bridge_test.go deleted file mode 100644 index 05a128c8..00000000 --- a/cmd/server/packet_path_bridge_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -// TestMarkBridgeRepeaters_NilStore covers the graceful no-op when the -// in-memory store isn't available (s.store can legitimately be nil -- -// see handlePacketPath). Must not panic and must leave every IsBridge -// at its zero value (false). -func TestMarkBridgeRepeaters_NilStore(t *testing.T) { - resp := &PacketPathResponse{ - Hash: "deadbeef", - Branches: []PacketPathBranch{ - {Points: []PacketPathPoint{{PublicKey: "pk1"}}, Observer: &PacketPathObserver{PublicKey: "obs1"}}, - }, - } - markBridgeRepeaters(resp, nil, &Config{}) - if resp.Branches[0].Points[0].IsBridge { - t.Errorf("Points[0].IsBridge = true, want false -- store is nil, nothing should be marked") - } - if resp.Branches[0].Observer.IsBridge { - t.Errorf("Observer.IsBridge = true, want false -- store is nil, nothing should be marked") - } -} - -// TestMarkBridgeRepeaters_TwoScopesIsBridge covers the actual -// determination: a pubkey relaying 2+ distinct region scopes (the same -// definition/data source as the Foreign Traffic tab's "Bridge" badge, -// ScopeStatsResponse.bridgeRepeaters) gets IsBridge=true on every point -// and observer entry referencing it; a pubkey with 0 or 1 scope, or one -// missing from the relay map entirely, stays false. -func TestMarkBridgeRepeaters_TwoScopesIsBridge(t *testing.T) { - store := &PacketStore{ - repeaterRelayCache: map[string]RepeaterRelayInfo{ - "bridgepk": {TransportedScopes: []string{"#dk", "#se"}}, - "regionalpk": {TransportedScopes: []string{"#dk"}}, - "unknownscopepk": {TransportedScopes: nil}, - }, - repeaterRelayCacheWin: 24, - repeaterRelayAt: time.Now(), - } - resp := &PacketPathResponse{ - Hash: "deadbeef", - Branches: []PacketPathBranch{ - { - Points: []PacketPathPoint{ - {PublicKey: "bridgepk"}, - {PublicKey: "regionalpk"}, - {PublicKey: "unknownscopepk"}, - {PublicKey: "notinrelaymap"}, - }, - Observer: &PacketPathObserver{PublicKey: "BridgePK"}, // case-insensitive match - }, - }, - } - markBridgeRepeaters(resp, store, &Config{}) - - pts := resp.Branches[0].Points - if !pts[0].IsBridge { - t.Errorf("Points[0] (bridgepk, 2 scopes) IsBridge = false, want true") - } - if pts[1].IsBridge { - t.Errorf("Points[1] (regionalpk, 1 scope) IsBridge = true, want false") - } - if pts[2].IsBridge { - t.Errorf("Points[2] (unknownscopepk, 0 scopes) IsBridge = true, want false") - } - if pts[3].IsBridge { - t.Errorf("Points[3] (notinrelaymap, absent from relay map) IsBridge = true, want false") - } - if !resp.Branches[0].Observer.IsBridge { - t.Errorf("Observer (BridgePK, case-insensitive match on bridgepk) IsBridge = false, want true") - } -} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 2e42da72..d88fca6c 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3164,46 +3164,9 @@ func (s *Server) handlePacketPath(w http.ResponseWriter, r *http.Request) { writeError(w, 500, err.Error()) return } - markBridgeRepeaters(resp, s.store, s.cfg) writeJSON(w, resp) } -// markBridgeRepeaters sets IsBridge on every point/observer in resp whose -// pubkey has been confirmed relaying traffic for 2+ distinct region -// scopes -- the same "Bridge" definition/data source as the Foreign -// Traffic tab's badge (ScopeStatsResponse.bridgeRepeaters), just applied -// to whichever handful of pubkeys this one packet's path touches instead -// of scanning the whole mesh. No-op when the in-memory store isn't -// available (IsBridge stays false/omitted on every entry). -func markBridgeRepeaters(resp *PacketPathResponse, store *PacketStore, cfg *Config) { - if resp == nil || store == nil || cfg == nil { - return - } - relayMap := store.GetRepeaterRelayInfoMap(cfg.GetHealthThresholds().RelayActiveHours) - isBridge := func(pubkey string) bool { - if pubkey == "" { - return false - } - info, ok := relayMap[strings.ToLower(pubkey)] - return ok && len(info.TransportedScopes) >= 2 - } - mark := func(b *PacketPathBranch) { - if b == nil { - return - } - for i := range b.Points { - b.Points[i].IsBridge = isBridge(b.Points[i].PublicKey) - } - if b.Observer != nil { - b.Observer.IsBridge = isBridge(b.Observer.PublicKey) - } - } - for i := range resp.Branches { - mark(&resp.Branches[i]) - } - mark(resp.First) -} - var iataCoords = map[string]IataCoord{ "SJC": {Lat: 37.3626, Lon: -121.929}, "SFO": {Lat: 37.6213, Lon: -122.379}, diff --git a/public/packet-path-map.js b/public/packet-path-map.js index b73e0923..a6fca65f 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -100,7 +100,7 @@ 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, - approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, isBridge: !!p.isBridge, + approxNeighborCount: p.approxNeighborCount, approxSpreadKm: p.approxSpreadKm, role: p.role, publicKey: p.publicKey, }; }); @@ -112,7 +112,7 @@ lat: b.observer.lat, lon: b.observer.lon, name: b.observer.name, label: observerLabel, isObserver: true, approx: !!b.observer.approx, approxNeighborCount: b.observer.approxNeighborCount, approxSpreadKm: b.observer.approxSpreadKm, - role: b.observer.role, isBridge: !!b.observer.isBridge, publicKey: b.observer.publicKey, + role: b.observer.role, publicKey: b.observer.publicKey, }); } return { chain: chain, missing: (b.points || []).length - located.length }; @@ -129,7 +129,7 @@ '' + '

Relay Path

' + - '

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. A bold purple outline marks a confirmed bridge repeater. Click a marker to open that node\'s detail page.

' + + '

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.

' + '
' + '
Loading…
' + ''; @@ -217,24 +217,15 @@ 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 }; - if (pt.isBridge) { - // Bridge repeaters (confirmed relaying for 2+ regions -- same - // definition as the Foreign Traffic tab's badge) get a bold - // purple outline on top of whatever primary/approx styling - // already applies, so they stand out as the mesh's backbone - // nodes regardless of which branch they're in. - markerOpts = Object.assign({}, markerOpts, { color: cssVar('--status-purple'), weight: markerOpts.weight + 1 }); - } var approxNote = ''; if (pt.approx) { approxNote = ', approx. position'; if (pt.approxNeighborCount) approxNote += ' from ' + pt.approxNeighborCount + ' neighbor' + (pt.approxNeighborCount === 1 ? '' : 's'); } - var bridgeNote = pt.isBridge ? ', bridge repeater' : ''; var clickNote = pt.publicKey ? ' — click for node detail' : ''; var marker = L.circleMarker([pt.lat, pt.lon], markerOpts) .addTo(map) - .bindTooltip(roleIcon(pt.role) + escapeHtml(pt.name) + ' (' + pt.label + approxNote + bridgeNote + ')' + clickNote, { className: 'packet-path-tooltip' }); + .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). diff --git a/public/style.css b/public/style.css index 1a300c56..d1cdd903 100644 --- a/public/style.css +++ b/public/style.css @@ -2645,10 +2645,10 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .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, bridge, 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 within a fixed width instead. */ + 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 within a fixed width instead. */ .leaflet-tooltip.packet-path-tooltip { white-space: normal; max-width: 220px; diff --git a/test-packet-path-map.js b/test-packet-path-map.js index d3e4c53f..8d4aac5b 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -40,7 +40,7 @@ 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/bridge/distance/timing info are all combined', () => { +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'); @@ -454,46 +454,6 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ nodes with a known role get a role icon in their tooltip: ' + e.message); } })(); - await (async () => { - try { - // isBridge=true should get a bold purple outline (overriding the - // normal stroke color/weight) and a "bridge repeater" tooltip note. - const ctx = makeSandbox(() => Promise.resolve({ - hash: 'deadbeef', - branches: [ - { - hops: 1, - points: [ - { publicKey: 'pk1', name: 'PlainRepeater', lat: 56.0, lon: 10.0, isBridge: false }, - { publicKey: 'pk2', name: 'BridgeRepeater', lat: 56.1, lon: 10.1, isBridge: true }, - ], - observer: null, - }, - ], - })); - - const optsByTooltip = {}; - ctx.L = { - map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), - tileLayer: () => ({ addTo() { return this; } }), - circleMarker: (latlng, opts) => ({ addTo() { return this; }, bindTooltip(t) { optsByTooltip[t] = opts; return this; }, on() { return this; } }), - polyline: () => ({ addTo() { return this; } }), - }; - - await ctx.window.PacketPathMap.open('deadbeef'); - const plainKey = Object.keys(optsByTooltip).find((k) => k.includes('PlainRepeater')); - const bridgeKey = Object.keys(optsByTooltip).find((k) => k.includes('BridgeRepeater')); - assert.ok(plainKey, 'expected a tooltip for PlainRepeater'); - assert.ok(bridgeKey, 'expected a tooltip for BridgeRepeater'); - assert.ok(!plainKey.includes('bridge repeater'), 'PlainRepeater tooltip should not mention bridge, got: ' + plainKey); - assert.ok(bridgeKey.includes('bridge repeater'), 'BridgeRepeater tooltip should mention bridge, got: ' + bridgeKey); - assert.notStrictEqual(optsByTooltip[bridgeKey].color, optsByTooltip[plainKey].color, 'expected the bridge marker to use a distinct outline color'); - assert.ok(optsByTooltip[bridgeKey].weight > optsByTooltip[plainKey].weight, 'expected the bridge marker outline to be thicker'); - passed++; - console.log(' ✅ bridge repeaters get a distinct outline and tooltip note'); - } catch (e) { failed++; console.log(' ❌ bridge repeaters get a distinct outline and tooltip note: ' + e.message); } - })(); - await (async () => { try { // A marker with a publicKey should register a click handler that From d1888618ce86d6088364ed192b4497c3866953ee Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 19:36:07 +0200 Subject: [PATCH 09/10] fix: View Path tooltip still wrapped too narrow at larger font sizes max-width was a fixed 220px; at bigger font/zoom settings that left room for barely one word per line. Use 28ch instead so the box scales with the actual font size. --- public/style.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/style.css b/public/style.css index d1cdd903..b419d96a 100644 --- a/public/style.css +++ b/public/style.css @@ -2648,10 +2648,12 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } 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 within a fixed width instead. */ + 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. */ .leaflet-tooltip.packet-path-tooltip { white-space: normal; - max-width: 220px; + max-width: 28ch; } /* For Leaflet layer control */ From ae16d232638e109b2c32df7436f35566b84cdf59 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 19:44:58 +0200 Subject: [PATCH 10/10] fix: View Path tooltip renders as a tall single-column rectangle The tooltip's containing pane has no intrinsic width, so a plain width:auto block collapsed to min-content (one word per line) and never grew toward max-width at all. width:max-content makes it size to its content up to max-width, producing a normal wrapped box. Verified live on stg by patching the CSS in-page and re-triggering a tooltip before shipping. --- public/style.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index b419d96a..e865b785 100644 --- a/public/style.css +++ b/public/style.css @@ -2650,9 +2650,14 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } 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. */ + 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; }