feat(rx-coverage): show where a companion drove and received nothing (#2172)

The Mobile RX coverage page (`#/rx-coverage`) shows only cells where a
companion received something. Where a companion drove and received
nothing is just as useful for operators, and the data is already there:
the RF sample track (`client_rf_samples`, #1905/#1906) and the
receptions (`client_receptions`) use the same hex grid (`hexCellAt`,
`zoomToHexRes`). This PR adds those cells as a hatched layer under the
reception cells.

## Changes in this PR

**Server**
- `GET /api/rx-coverage?...&gaps=1` adds a `gaps` member to the
FeatureCollection: cells with moving (non-stationary) track samples and
no reception in the same `days` window and `bbox`, each with `samples`.
`gaps_truncated` is set when more than 5000 cells qualify (the
most-sampled are kept, same pattern as `coverageFeatureCap` and
`rfNoiseFeatureCap`).
- Gaps are only returned when `clientRfSamples` is on and there is no
`node` filter: with `?node=` the question would be "this node was not
heard", which these cells do not answer. `?rx=` limits both the track
and the receptions to that companion.
- Without `gaps=1` the payload is unchanged: the `gaps` member is
omitted.
- New code lives in `cmd/server/rx_coverage_gaps.go` (pure
`aggregateGaps` plus `queryTrackPoints`); the handler change in
`rx_dashboard.go` is 14 lines.
- `/api/rx-coverage` is now documented in `openapi.go` and removed from
`openapi_known_gaps.json` (#1670).

**Frontend** (`public/rx-coverage.js`, `public/node-reach-coverage.css`)
- A "Nothing received" toggle on the signal layer, on by default, only
rendered with `MC_CLIENT_RF_SAMPLES`. Hidden on the noise layer.
- Gap cells use a 45-degree hatch (an SVG pattern in a dedicated
renderer, so they sit under the reception cells). The hatch is denser
for cells driven through more often (1 / 2-3 / 4+ samples). Tooltip:
sample count. Legend entry: "driven, nothing received".
- Off state is deep-linkable as `gaps=0`.
- New token `--nq-cov-gap` for light and dark. Plain grey
(`--nq-cov-grey`) already means "received, no SNR", so the hatch keeps
the two apart.

## Threshold: one sample

Measured on a live network (analyzer.on8ar.eu, Belgium, September to
October 2026), with cells of about 250 m: a cell with track samples and
no reception in one half of a month, driven again in the other half. How
often did it receive something the second time?

| Minimum samples | Cells "nothing received" | Driven again | Received
something later |
|---|---|---|---|
| 1 | 3971 | 1263 | 29% |
| 2 | 1565 | 592 | 32% |
| 3 | 619 | 243 | 35% |
| 5 | 142 | 63 | 40% |

A higher threshold hides cells without making the rest more reliable, so
the threshold is 1 and the wording is "nothing received in this period",
never "no coverage". Using every decoded packet as reception evidence
(including 1-byte path hashes) gave 25% to 32%, so the capture rule for
1-byte hashes is not what drives this. The median cell has 2 samples.

## Verification

- Go: 7 new tests in `rx_coverage_gaps_test.go` (gap/no gap, stationary
excluded, single sample, cap keeps the most-sampled, endpoint, `?rx=`
filter, no `gaps` member when not asked / with `?node=` / with RF
samples off). The endpoint tests fail without the handler change. Full
`cmd/server` suite green locally, `go vet` and `gofmt` clean.
- E2E: `tests/e2e/test-rx-coverage-gaps-e2e.js` (4 steps: no toggle with
RF samples off, on by default with hatched cells under the reception
cells, toggle off with `gaps=0` and deep link, noise layer hides it). 2
steps fail without the frontend change. Existing
`test-rx-coverage-noise-e2e.js` and `test-rx-coverage-viewport-e2e.js`
still pass. Listed in `scripts/non-unit-tests.json`.
- Frontend unit suite (`test-all.sh`) green.
- Real data on a staging instance (7 days, bbox 50.8,4.9,51.3,5.8,
z=12): 478 reception cells and 379 gap cells, no cell in both, reception
features identical with and without `gaps=1`, 0.48 s to 0.52 s per
request both ways.
- Browser: checked on staging with real data (light theme), and light,
dark and 390 px wide with mocked data.

## Not in this PR

- A per-node variant on the Reach page ("this repeater transmitted while
a companion was nearby, and was not heard"). That needs the transmit
moments from `observations` and is a heavier query.
- `deploy.yml` does not get a line for the new E2E file, like the other
entries that are only in `non-unit-tests.json` (#2037).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efitenandClaude Opus 5.5 authored and GitHub committed 2026-10-10 17:39:33 +02:00
1 parent 673442ccb9
commit f3004d977e
10 files changed
+532 -6

No files matched your search

+9
View File
@@ -152,6 +152,15 @@ func routeDescriptions() map[string]routeMeta {
{Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"},
{Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"},
}},
"GET /api/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage",
QueryParams: []paramMeta{
{Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true},
{Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"},
{Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"},
{Name: "node", Description: "Only receptions of this node (pubkey or 2-3 byte prefix); disables gaps", Type: "string"},
{Name: "rx", Description: "Only this companion's receptions (and, with gaps=1, its own track)", Type: "string"},
{Name: "gaps", Description: "1 = add cells driven through with nothing received", Type: "string"},
}},
"GET /api/scope-audit": {Summary: "Network-wide scope audit", Description: "For every repeater that has answered a declared-regions request: the regions it declares, which of those it has NOT been observed forwarding in the window, which scopes it forwards without declaring, and whether it forwards unscoped floods while omitting the '*' wildcard. '*' is never listed as a region — it governs unscoped floods, not a scope. Repeaters never successfully asked are absent rather than shown as declaring nothing. Rows with missing regions sort first; a short window is weak evidence, since a quiet region simply has no traffic.", Tag: "analytics",
QueryParams: []paramMeta{
{Name: "window", Description: "Time window: 1h, 24h, or 7d (default 24h)", Type: "string"},
-1
View File
@@ -23,7 +23,6 @@
"/api/perf/io",
"/api/perf/sqlite",
"/api/perf/write-sources",
"/api/rx-coverage",
"/api/rx-leaderboard",
"/api/scope-stats",
"/api/spec"
+4
View File
@@ -41,6 +41,10 @@ type CoverageFeatureCollection struct {
// members, omitempty so the global endpoint's payload is unchanged (#3).
MobileReceptions int `json:"mobile_receptions,omitempty"`
MobileClients int `json:"mobile_clients,omitempty"`
// Gaps: cells driven through with nothing received (rx_coverage_gaps.go).
// Only with ?gaps=1 on the global endpoint; omitted otherwise.
Gaps []CoverageGap `json:"gaps,omitempty"`
GapsTruncated bool `json:"gaps_truncated,omitempty"`
}
type CoverageFeature struct {
Type string `json:"type"` // "Feature"
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"sort"
"strings"
"time"
)
// "Nothing received" cells for the coverage map: hex cells a companion drove
// through (moving client_rf_samples) in which no client_receptions row lies in
// the same window. Served as the `gaps` foreign member of /api/rx-coverage when
// asked with ?gaps=1, so the reception features stay exactly what they were.
//
// One moving sample is enough. Measured on the live network (2026-09/10): of
// cells flagged with nothing received in one half of the period and driven again
// in the other, 29% (1 sample), 32% (2), 35% (3) and 40% (5) did have a
// reception the second time. A higher threshold only hides cells; it does not
// make the rest more reliable. Hence the wording "nothing received in this
// period", never "no coverage".
// coverageGapCap bounds the gap cells per response, like coverageFeatureCap
// and rfNoiseFeatureCap.
const coverageGapCap = 5000
type CoverageGap struct {
Type string `json:"type"` // "Feature"
Geometry CoveragePolygon `json:"geometry"`
Properties CoverageGapProperties `json:"properties"`
}
type CoverageGapProperties struct {
Cell string `json:"cell"`
Samples int `json:"samples"` // moving track samples in the cell
}
// trackPoint is one client_rf_samples GPS fix: the companion was connected
// to the app at that place and time.
type trackPoint struct {
Lat, Lon float64
Stationary bool
}
// aggregateGaps returns the cells with moving track samples and no reception,
// sorted by cell. When more than coverageGapCap qualify, the most-sampled are
// kept and truncated is true.
func aggregateGaps(track []trackPoint, receptions []coverageRow, res int) (gaps []CoverageGap, truncated bool) {
heard := make(map[string]bool, len(receptions))
for _, r := range receptions {
heard[hexCellAt(r.Lat, r.Lon, res)] = true
}
samples := map[string]int{}
for _, p := range track {
if p.Stationary {
continue
}
if c := hexCellAt(p.Lat, p.Lon, res); !heard[c] {
samples[c]++
}
}
gaps = []CoverageGap{}
for cell, n := range samples {
ring := hexBoundary(cell)
if ring == nil {
continue
}
gaps = append(gaps, CoverageGap{
Type: "Feature",
Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}},
Properties: CoverageGapProperties{Cell: cell, Samples: n},
})
}
if len(gaps) > coverageGapCap {
sort.Slice(gaps, func(i, j int) bool {
if gaps[i].Properties.Samples != gaps[j].Properties.Samples {
return gaps[i].Properties.Samples > gaps[j].Properties.Samples
}
return gaps[i].Properties.Cell < gaps[j].Properties.Cell
})
gaps, truncated = gaps[:coverageGapCap], true
}
sort.Slice(gaps, func(i, j int) bool { return gaps[i].Properties.Cell < gaps[j].Properties.Cell })
return gaps, truncated
}
// queryTrackPoints returns client_rf_samples GPS fixes within a bbox and time
// window (days; 0 = all time), optionally for one companion. Unlike the noise
// layer it keeps rows without a noise_floor reading: any sample proves the
// companion was connected there. Read-only.
func (s *Server) queryTrackPoints(rx string, days int, b bbox) ([]trackPoint, error) {
where := []string{"lat BETWEEN ? AND ?", "lon BETWEEN ? AND ?", "stationary = 0"}
args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon}
if rx != "" {
where = append(where, "rx_pubkey = ?")
args = append(args, strings.ToLower(rx))
}
if days > 0 {
where = append(where, "sampled_at >= ?")
args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339))
}
rows, err := s.db.conn.Query("SELECT lat, lon FROM client_rf_samples WHERE "+strings.Join(where, " AND "), args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []trackPoint{}
for rows.Next() {
var p trackPoint
if err := rows.Scan(&p.Lat, &p.Lon); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
)
// --- aggregateGaps: pure ---
func TestAggregateGapsTrackWithoutReception(t *testing.T) {
res := zoomToHexRes(12)
track := []trackPoint{{Lat: 51.05, Lon: 3.72}, {Lat: 51.05, Lon: 3.72}, {Lat: 51.30, Lon: 3.90}}
rx := []coverageRow{{Lat: 51.30, Lon: 3.90}}
gaps, truncated := aggregateGaps(track, rx, res)
if truncated {
t.Fatal("two cells must not be truncated")
}
if len(gaps) != 1 {
t.Fatalf("want 1 gap (the cell without a reception), got %d: %+v", len(gaps), gaps)
}
g := gaps[0]
if g.Type != "Feature" || g.Geometry.Type != "Polygon" || len(g.Geometry.Coordinates[0]) == 0 {
t.Fatalf("gap must be a GeoJSON polygon feature, got %+v", g)
}
if g.Properties.Cell != hexCellAt(51.05, 3.72, res) || g.Properties.Samples != 2 {
t.Fatalf("want the 51.05/3.72 cell with 2 samples, got %+v", g.Properties)
}
}
// A parked companion is not a drive through the cell: stationary samples never make a gap.
func TestAggregateGapsIgnoresStationary(t *testing.T) {
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72, Stationary: true}}, nil, zoomToHexRes(12))
if len(gaps) != 0 {
t.Fatalf("stationary sample must not produce a gap, got %+v", gaps)
}
}
// One moving sample is enough (threshold measured: more samples do not make a gap more reliable).
func TestAggregateGapsSingleSample(t *testing.T) {
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72}}, nil, zoomToHexRes(12))
if len(gaps) != 1 || gaps[0].Properties.Samples != 1 {
t.Fatalf("one moving sample without reception must be a gap, got %+v", gaps)
}
}
func TestAggregateGapsCapsKeepsMostSampled(t *testing.T) {
res := zoomToHexRes(14)
var track []trackPoint
seen := map[string]bool{}
for i := 0; len(seen) < coverageGapCap+5; i++ {
la, lo := 51.0+float64(i%200)*0.002, 3.5+float64(i/200)*0.003
c := hexCellAt(la, lo, res)
if seen[c] {
continue
}
seen[c] = true
track = append(track, trackPoint{Lat: la, Lon: lo})
if len(seen) == 1 { // one cell gets many samples: it must survive the cap
for k := 0; k < 9; k++ {
track = append(track, trackPoint{Lat: la, Lon: lo})
}
}
}
gaps, truncated := aggregateGaps(track, nil, res)
if !truncated || len(gaps) != coverageGapCap {
t.Fatalf("want %d gaps and truncated, got %d truncated=%v", coverageGapCap, len(gaps), truncated)
}
found := false
for _, g := range gaps {
if g.Properties.Samples == 10 {
found = true
}
}
if !found {
t.Fatal("the most-sampled cell must be kept when capping")
}
for i := 1; i < len(gaps); i++ {
if gaps[i-1].Properties.Cell > gaps[i].Properties.Cell {
t.Fatal("gaps must be sorted by cell for a deterministic payload")
}
}
}
// --- endpoint ---
func seedGapsDB(t *testing.T) *DB {
db := seedCoverageDB(t)
mustExecDB(t, db, `CREATE TABLE client_rf_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT, sampled_at TEXT, ingested_at TEXT,
lat REAL, lon REAL, pos_acc_m REAL, stationary INTEGER NOT NULL DEFAULT 0,
uptime_secs INTEGER, battery_mv INTEGER, queue_len INTEGER, errors INTEGER,
noise_floor INTEGER, last_rssi INTEGER, last_snr REAL, tx_air_secs INTEGER,
rx_air_secs INTEGER, recv INTEGER, sent INTEGER, flood_rx INTEGER, direct_rx INTEGER,
flood_tx INTEGER, direct_tx INTEGER, recv_errors INTEGER)`)
return db
}
func insTrack(t *testing.T, db *DB, rx, at string, lat, lon float64, stationary int) {
mustExecDB(t, db, fmt.Sprintf(
`INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs) VALUES ('%s','%s','t',%f,%f,%d,1)`,
rx, at, lat, lon, stationary))
}
func serveCoverage(srv *Server, path string) *httptest.ResponseRecorder {
router := mux.NewRouter()
router.HandleFunc("/api/rx-coverage", srv.handleRxCoverage).Methods("GET")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
return rr
}
func gapsServer(t *testing.T, rfOn bool) *Server {
db := seedGapsDB(t)
// seedCoverageDB already holds receptions; start from a known state.
mustExecDB(t, db, `DELETE FROM client_receptions`)
now := time.Now().UTC().Format(time.RFC3339)
old := time.Now().UTC().AddDate(0, 0, -40).Format(time.RFC3339)
insRx(t, db, "compa", "aabbcc", now, 51.30, 3.90) // heard here
insTrack(t, db, "compa", now, 51.30, 3.90, 0) // driven and heard: no gap
insTrack(t, db, "compa", now, 51.05, 3.72, 0) // driven, nothing: gap
insTrack(t, db, "compb", now, 51.10, 3.60, 0) // other companion, nothing: gap unless ?rx=compa
insTrack(t, db, "compa", now, 51.20, 3.50, 1) // parked: no gap
insTrack(t, db, "compa", old, 51.40, 3.40, 0) // outside the 7-day window: no gap
cfg := &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}
if rfOn {
cfg.ClientRfSamples = &ClientRfSamplesConfig{Enabled: true}
}
return &Server{db: db, cfg: cfg}
}
func decodeCoverage(t *testing.T, rr *httptest.ResponseRecorder) CoverageFeatureCollection {
t.Helper()
if rr.Code != 200 {
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
}
var fc CoverageFeatureCollection
if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil {
t.Fatalf("decode: %v", err)
}
return fc
}
func TestRxCoverageGapsEndpoint(t *testing.T) {
srv := gapsServer(t, true)
z := 12
fc := decodeCoverage(t, serveCoverage(srv, fmt.Sprintf("/api/rx-coverage?bbox=50,3,52,4&z=%d&days=7&gaps=1", z)))
if len(fc.Features) != 1 {
t.Fatalf("the reception layer must be unchanged: want 1 feature, got %d", len(fc.Features))
}
res := zoomToHexRes(z)
want := map[string]bool{hexCellAt(51.05, 3.72, res): true, hexCellAt(51.10, 3.60, res): true}
if len(fc.Gaps) != len(want) {
t.Fatalf("want %d gaps, got %+v", len(want), fc.Gaps)
}
for _, g := range fc.Gaps {
if !want[g.Properties.Cell] {
t.Fatalf("unexpected gap cell %s", g.Properties.Cell)
}
}
}
func TestRxCoverageGapsFollowObserverFilter(t *testing.T) {
srv := gapsServer(t, true)
fc := decodeCoverage(t, serveCoverage(srv, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&rx=compa"))
if len(fc.Gaps) != 1 || fc.Gaps[0].Properties.Cell != hexCellAt(51.05, 3.72, zoomToHexRes(12)) {
t.Fatalf("?rx=compa must only show compa's own drive, got %+v", fc.Gaps)
}
}
// Without ?gaps=1, with a node filter, or with the RF sample stream off, the payload carries no gaps member at all.
func TestRxCoverageGapsOnlyWhenAskedAndAvailable(t *testing.T) {
cases := []struct {
name string
rfOn bool
path string
}{
{"not asked", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7"},
{"node filter", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&node=aabbcc"},
{"rf samples off", false, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1"},
}
for _, c := range cases {
rr := serveCoverage(gapsServer(t, c.rfOn), c.path)
if rr.Code != 200 {
t.Fatalf("%s: status %d", c.name, rr.Code)
}
if strings.Contains(rr.Body.String(), `"gaps"`) {
t.Fatalf("%s: payload must not carry gaps, got %s", c.name, rr.Body.String())
}
}
}
+13 -1
View File
@@ -217,12 +217,24 @@ func (s *Server) handleRxCoverage(w http.ResponseWriter, r *http.Request) {
}
days := clampDays(atoiDefault(r.URL.Query().Get("days"), 7))
z, _ := strconv.Atoi(r.URL.Query().Get("z"))
rows, err := s.queryCoverageFiltered(r.URL.Query().Get("node"), r.URL.Query().Get("rx"), days, b)
node, rx := r.URL.Query().Get("node"), r.URL.Query().Get("rx")
rows, err := s.queryCoverageFiltered(node, rx, days, b)
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
fc := aggregateCoverage(rows, zoomToHexRes(z), s.heardKeyResolverFor(rows))
// "Nothing received" cells need the track, so only with the RF sample
// stream on; with ?node= the question would be "this node not heard",
// which these cells do not answer.
if r.URL.Query().Get("gaps") == "1" && node == "" && s.cfg.ClientRfSamplesEnabled() {
track, err := s.queryTrackPoints(rx, days, b)
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
fc.Gaps, fc.GapsTruncated = aggregateGaps(track, rows, zoomToHexRes(z))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(fc)
}
+4
View File
@@ -10,6 +10,7 @@
--nq-cov-mid: #e67e22; /* SF8: −9..−5 dB (near the limit) */
--nq-cov-weak: #e74c3c; /* SF8: < −9 dB (poor, packet loss likely) */
--nq-cov-grey: #95a5a6; /* heard, no SNR metric */
--nq-cov-gap: #57606a; /* hatch: driven through, nothing received */
}
/* Dark-theme variants: the saturated mid-luminance defaults glare on dark
basemaps. Mirrors the rest of the dashboard's theme-aware tokens (#polish). */
@@ -18,12 +19,15 @@
--nq-cov-mid: #d29922;
--nq-cov-weak: #f85149;
--nq-cov-grey: #8b949e;
--nq-cov-gap: #c9d1d9;
}
.nq-cov-legend { display:flex; gap:12px; align-items:center; font-size:11px; color:var(--text-muted, #57606a); margin:4px 0 10px; }
/* Toggled by node-reach.js applyCoverage via class, not inline style, so CSS
(print rules, themes) can still override visibility (#19). */
.nq-cov-legend.is-hidden { display:none !important; }
.nq-cov-legend i { width:12px; height:12px; border-radius:2px; display:inline-block; margin-right:4px; vertical-align:middle; }
/* Same 45-degree hatch as the map's gap cells (rx-coverage.js ensureGapHatch). */
.nq-cov-legend i.nq-cov-gap-swatch { background:repeating-linear-gradient(45deg, var(--nq-cov-gap) 0 1.5px, transparent 1.5px 4px); outline:1px solid var(--nq-cov-gap); outline-offset:-1px; }
/* Mobile RX coverage hub — leaderboard */
.rxb { display:flex; flex-direction:column; gap:3px; }
+82 -4
View File
@@ -10,6 +10,15 @@
// layer: 'signal' (default, per-cell best SNR of directly-heard nodes) or
// 'noise' (RF noise-floor layer, opt-in — see MC_CLIENT_RF_SAMPLES).
var layer = 'signal';
// showGaps: on the signal layer, also draw cells a companion drove through
// with nothing received (/api/rx-coverage?gaps=1). Needs the RF sample
// track, so only offered with MC_CLIENT_RF_SAMPLES. Default on.
var showGaps = true;
// gapRenderer: an SVG renderer of its own, added before the coverage
// polygons so gap cells sit underneath them and it can hold the hatch <defs>.
var gapRenderer = null;
function gapsAvailable() { return !!window.MC_CLIENT_RF_SAMPLES; }
function cssColor(varName) {
try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; }
@@ -63,9 +72,50 @@
'<span><i style="background:var(--nq-cov-mid)"></i>medium</span>' +
'<span><i style="background:var(--nq-cov-weak)"></i>weak</span>' +
'<span><i style="background:var(--nq-cov-grey)"></i>no signal</span>' +
(gapsAvailable() && showGaps ? '<span><i class="nq-cov-gap-swatch"></i>driven, nothing received</span>' : '') +
'</div>';
}
function gapsBtnHtml() {
return '<button id="rxGapsBtn" data-gaps="1"' + (showGaps ? ' class="active"' : '') + ' aria-pressed="' + (showGaps ? 'true' : 'false') + '"' +
' title="Cells a companion drove through in this period without receiving anything. This is not proof of no coverage: at the edge of a node\'s range, another drive may well receive something.">Nothing received</button>';
}
// gapFillOpacity: denser hatch for cells driven through more often (1 / 2-3 / 4+ samples).
function gapFillOpacity(p) {
var n = p ? Number(p.samples) : 0;
return n >= 4 ? 0.95 : n >= 2 ? 0.75 : 0.55;
}
function gapCellHtml(p) {
if (!p) return '';
return '<div style="font-weight:600;margin-bottom:4px">Nothing received here in this period</div>' +
'<div style="font-size:12px;color:var(--text-muted)">' + p.samples + (p.samples === 1 ? ' track sample' : ' track samples') + ' while driving</div>';
}
// ensureGapHatch creates the gap renderer once per map and puts the 45-degree
// hatch pattern in its <svg>, coloured by --nq-cov-gap so themes follow.
function ensureGapHatch() {
if (gapRenderer || !map) return;
gapRenderer = L.svg({ padding: 0.5 }).addTo(map);
var svg = gapRenderer._container;
if (!svg) return;
var ns = 'http://www.w3.org/2000/svg';
var defs = document.createElementNS(ns, 'defs');
var pat = document.createElementNS(ns, 'pattern');
pat.setAttribute('id', 'rxGapHatch');
pat.setAttribute('patternUnits', 'userSpaceOnUse');
pat.setAttribute('width', '6');
pat.setAttribute('height', '6');
pat.setAttribute('patternTransform', 'rotate(45)');
var line = document.createElementNS(ns, 'line');
line.setAttribute('x1', '0'); line.setAttribute('y1', '0'); line.setAttribute('x2', '0'); line.setAttribute('y2', '6');
line.setAttribute('style', 'stroke:var(--nq-cov-gap);stroke-width:2');
pat.appendChild(line);
defs.appendChild(pat);
svg.insertBefore(defs, svg.firstChild);
}
// subtitleHtml reflects the active layer (#7): the "Colour = ..." clause
// must not keep claiming best-signal colouring while the noise legend (an
// inverted, opposite-direction colour axis) sits right below it.
@@ -76,7 +126,8 @@
function pageHtml() {
var layerBar = window.MC_CLIENT_RF_SAMPLES
? '<div class="analytics-time-range" id="rxLayerBar" style="margin:8px 0">' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '</div>'
? '<div class="analytics-time-range" id="rxLayerBar" style="margin:8px 0">' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '</div>' +
'<div class="analytics-time-range" id="rxGapsBar" style="margin:8px 0' + (layer === 'signal' ? '' : ';display:none') + '">' + gapsBtnHtml() + '</div>'
: '';
return '<div style="max-width:1100px;margin:0 auto;padding:12px 16px">' +
'<h2 style="margin:4px 0 2px;font-size:18px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-map-trifold"/></svg> Mobile RX coverage</h2>' +
@@ -153,11 +204,21 @@
}
function drawSignalLayer(bbox) {
var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '');
var wantGaps = gapsAvailable() && showGaps;
var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '') + (wantGaps ? '&gaps=1' : '');
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
if (destroyed || !covLayer || layer !== 'signal') return;
covLayer.clearLayers();
setNoiseEmpty(false);
if (wantGaps && showGaps) {
ensureGapHatch();
var gapCol = cssColor('--nq-cov-gap');
(fc.gaps || []).forEach(function (f) {
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; });
L.polygon(ring, { renderer: gapRenderer, color: gapCol, weight: 0.5, opacity: 0.6, fillColor: 'url(#rxGapHatch)', fillOpacity: gapFillOpacity(f.properties), className: 'rx-gap-cell' })
.addTo(covLayer).bindTooltip(gapCellHtml(f.properties));
});
}
(fc.features || []).forEach(function (f) {
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; });
var col = cssColor(colorVar(f.properties));
@@ -359,12 +420,25 @@
if (legend) legend.outerHTML = legendHtml();
var subtitle = document.getElementById('rxSubtitle');
if (subtitle) subtitle.innerHTML = subtitleHtml();
var gapsBar = document.getElementById('rxGapsBar');
if (gapsBar) gapsBar.style.display = l === 'signal' ? '' : 'none';
setNoiseEmpty(false);
drawCoverage(); syncHash();
}
// setGaps toggles the "nothing received" cells on the signal layer.
function setGaps(on) {
showGaps = on;
var btn = document.getElementById('rxGapsBtn');
if (btn) { btn.classList.toggle('active', on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); }
var legend = document.getElementById('rxLegend');
if (legend) legend.outerHTML = legendHtml();
if (covLayer) covLayer.clearLayers();
drawCoverage(); syncHash();
}
function syncHash() {
var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '') + (layer !== 'signal' ? '&layer=' + layer : '');
var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '') + (layer !== 'signal' ? '&layer=' + layer : '') + (gapsAvailable() && !showGaps ? '&gaps=0' : '');
if (map) {
var c = map.getCenter();
q += '&lat=' + c.lat.toFixed(5) + '&lon=' + c.lng.toFixed(5) + '&zoom=' + map.getZoom();
@@ -388,13 +462,14 @@
container.innerHTML = '<div class="nq-msg">Coverage is not enabled on this deployment.</div>';
return;
}
selectedRx = ''; selectedName = ''; days = 7; boardCache = []; layer = 'signal';
selectedRx = ''; selectedName = ''; days = 7; boardCache = []; layer = 'signal'; showGaps = true;
try {
var p = (typeof getHashParams === 'function') ? getHashParams() : null;
if (p) {
var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd;
selectedRx = (p.get('rx') || '').toLowerCase();
if (window.MC_CLIENT_RF_SAMPLES && p.get('layer') === 'noise') layer = 'noise';
if (p.get('gaps') === '0') showGaps = false;
}
} catch (e) {}
container.innerHTML = pageHtml();
@@ -437,6 +512,8 @@
if (bar) bar.addEventListener('click', function (e) { var b = e.target.closest('button[data-days]'); if (b) setDays(+b.dataset.days); });
var layerBar = document.getElementById('rxLayerBar');
if (layerBar) layerBar.addEventListener('click', function (e) { var b = e.target.closest('button[data-layer]'); if (b) setLayer(b.dataset.layer); });
var gapsBtn = document.getElementById('rxGapsBtn');
if (gapsBtn) gapsBtn.addEventListener('click', function () { setGaps(!showGaps); });
setTimeout(function () { if (!destroyed && current === generation && map) { map.invalidateSize(); if (selectedRx && !explicitViewport) fitToObserver(); else drawCoverage(); } }, 150);
loadBoard();
}
@@ -446,6 +523,7 @@
generation++;
if (map) { try { map.remove(); } catch (e) {} map = null; }
covLayer = null;
gapRenderer = null;
}
registerPage('rx-coverage', { init: init, destroy: destroy });
+1
View File
@@ -107,6 +107,7 @@
"tests/e2e/test-packets-scope-column.js",
"tests/e2e/test-path-inspector-coverage-e2e.js",
"tests/e2e/test-rx-coverage-mobile-nav-e2e.js",
"tests/e2e/test-rx-coverage-gaps-e2e.js",
"tests/e2e/test-rx-coverage-noise-e2e.js",
"tests/e2e/test-rx-coverage-viewport-e2e.js",
"tests/e2e/test-show-neighbors.js",
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
'use strict';
// "Nothing received" cells on the Mobile RX coverage page: the toggle exists
// only with clientRfSamples, is on by default and asks /api/rx-coverage for
// gaps=1, draws the gap cells hatched underneath the reception cells, and the
// off state is deep-linkable (gaps=0) and stops asking for gaps.
const assert = require('assert');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
const poly = (lon, lat) => ({ type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] });
const recv = (lon, lat) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { cell: 'r' + lon, count: 2, best_snr: -4, has_sig: true, nodes: [] } });
const gap = (lon, lat, samples) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { cell: 'g' + lon, samples } });
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); }
}
async function openPage(browser, { rfSamples, hash }) {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
const covRequests = [];
await page.route('**/api/config/client', async route => {
const response = await route.fetch();
await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } });
});
await page.route('**/api/config/map', route => route.fulfill({ json: { center: [51, 4], zoom: 9 } }));
await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } }));
await page.route('**/api/rx-coverage?*', route => {
const url = route.request().url();
covRequests.push(url);
const withGaps = new URL(url).searchParams.get('gaps') === '1';
return route.fulfill({ json: { type: 'FeatureCollection', features: [recv(4.40, 51.20)], ...(withGaps ? { gaps: [gap(4.50, 51.20, 1), gap(4.60, 51.20, 5)] } : {}) } });
});
await page.goto(BASE + '/#/rx-coverage' + (hash || ''));
await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 });
return { page, errors, covRequests };
}
const gapCells = page => page.locator('#rxMap path.rx-gap-cell');
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
console.log('\n=== RX coverage "nothing received" E2E against ' + BASE + ' ===');
try {
await step('no toggle and no gaps request when clientRfSamples is off', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: false });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 1, null, { timeout: 10000 });
assert.strictEqual(await page.locator('#rxGapsBtn').count(), 0, 'toggle must not render when the feature is off');
assert.ok(covRequests.every(u => !/[?&]gaps=1/.test(u)), 'no gaps=1 request without RF samples');
assert.deepStrictEqual(errors, []);
await page.close();
});
await step('on by default: gaps=1 requested, hatched cells drawn under the reception cells, legend entry', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: true });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
assert.ok(covRequests.some(u => /[?&]gaps=1/.test(u)), 'the signal layer asks for gaps');
assert.strictEqual(await page.locator('#rxGapsBtn').getAttribute('aria-pressed'), 'true');
assert.strictEqual(await gapCells(page).first().getAttribute('fill'), 'url(#rxGapHatch)', 'gap cells use the hatch pattern');
assert.strictEqual(await page.locator('#rxMap pattern#rxGapHatch').count(), 1, 'the hatch pattern exists once');
const below = await page.evaluate(() => {
const g = document.querySelector('#rxMap path.rx-gap-cell'), r = document.querySelector('#rxMap path.leaflet-interactive:not(.rx-gap-cell)');
return !!(g && r && (g.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING));
});
assert.ok(below, 'gap cells are drawn before (under) the reception cells');
assert.ok(/nothing received/i.test(await page.locator('#rxLegend').innerText()), 'legend names the gap cells');
assert.deepStrictEqual(errors, []);
await page.close();
});
await step('toggle off: cells gone, gaps=0 in the hash, no more gaps requests; the link restores it', async () => {
const { page, errors, covRequests } = await openPage(browser, { rfSamples: true });
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
await page.click('#rxGapsBtn');
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 0, null, { timeout: 10000 });
assert.ok(/[?&]gaps=0/.test(await page.evaluate(() => location.hash)), 'off state is in the URL hash');
assert.ok(!/[?&]gaps=1/.test(covRequests[covRequests.length - 1]), 'the request after switching off does not ask for gaps');
assert.ok(!/nothing received/i.test(await page.locator('#rxLegend').innerText()), 'legend entry removed');
await page.close();
const again = await openPage(browser, { rfSamples: true, hash: '?gaps=0' });
await again.page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 1, null, { timeout: 10000 });
assert.strictEqual(await again.page.locator('#rxGapsBtn').getAttribute('aria-pressed'), 'false', 'deep link opens with the toggle off');
assert.strictEqual(await gapCells(again.page).count(), 0);
assert.deepStrictEqual(errors.concat(again.errors), []);
await again.page.close();
});
await step('the noise layer hides the toggle and draws no gap cells', async () => {
const { page, errors } = await openPage(browser, { rfSamples: true });
await page.route('**/api/rf-noise?*', route => route.fulfill({ json: { type: 'FeatureCollection', features: [] } }));
await page.waitForFunction(() => document.querySelectorAll('#rxMap path.rx-gap-cell').length === 2, null, { timeout: 10000 });
assert.ok(await page.locator('#rxGapsBar').isVisible(), 'toggle visible on the signal layer');
await page.click('#rxLayerBar button[data-layer="noise"]');
assert.ok(!(await page.locator('#rxGapsBar').isVisible()), 'toggle hidden on the noise layer');
await page.waitForTimeout(500);
assert.strictEqual(await gapCells(page).count(), 0);
assert.deepStrictEqual(errors, []);
await page.close();
});
} finally {
await browser.close();
}
console.log(`\ntest-rx-coverage-gaps-e2e: ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();