diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 3f2f29dc..e021a0fe 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -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"}, diff --git a/cmd/server/openapi_known_gaps.json b/cmd/server/openapi_known_gaps.json index 7c87182a..910559cb 100644 --- a/cmd/server/openapi_known_gaps.json +++ b/cmd/server/openapi_known_gaps.json @@ -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" diff --git a/cmd/server/rx_coverage.go b/cmd/server/rx_coverage.go index be2080ed..c5b98bf0 100644 --- a/cmd/server/rx_coverage.go +++ b/cmd/server/rx_coverage.go @@ -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" diff --git a/cmd/server/rx_coverage_gaps.go b/cmd/server/rx_coverage_gaps.go new file mode 100644 index 00000000..1d99cd2c --- /dev/null +++ b/cmd/server/rx_coverage_gaps.go @@ -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() +} diff --git a/cmd/server/rx_coverage_gaps_test.go b/cmd/server/rx_coverage_gaps_test.go new file mode 100644 index 00000000..0da5dd52 --- /dev/null +++ b/cmd/server/rx_coverage_gaps_test.go @@ -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()) + } + } +} diff --git a/cmd/server/rx_dashboard.go b/cmd/server/rx_dashboard.go index c4ae3b47..c8cf91dc 100644 --- a/cmd/server/rx_dashboard.go +++ b/cmd/server/rx_dashboard.go @@ -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) } diff --git a/public/node-reach-coverage.css b/public/node-reach-coverage.css index b58e88ff..dec1692b 100644 --- a/public/node-reach-coverage.css +++ b/public/node-reach-coverage.css @@ -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; } diff --git a/public/rx-coverage.js b/public/rx-coverage.js index a1ebb665..1a9adda9 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -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 . + 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 @@ 'medium' + 'weak' + 'no signal' + + (gapsAvailable() && showGaps ? 'driven, nothing received' : '') + ''; } + function gapsBtnHtml() { + return ''; + } + + // 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 '
Nothing received here in this period
' + + '
' + p.samples + (p.samples === 1 ? ' track sample' : ' track samples') + ' while driving
'; + } + + // ensureGapHatch creates the gap renderer once per map and puts the 45-degree + // hatch pattern in its , 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 - ? '
' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '
' + ? '
' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '
' + + '
' + gapsBtnHtml() + '
' : ''; return '
' + '

Mobile RX coverage

' + @@ -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 = '
Coverage is not enabled on this deployment.
'; 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 }); diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index a5fbb97a..155e7611 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -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", diff --git a/tests/e2e/test-rx-coverage-gaps-e2e.js b/tests/e2e/test-rx-coverage-gaps-e2e.js new file mode 100644 index 00000000..f036e3f2 --- /dev/null +++ b/tests/e2e/test-rx-coverage-gaps-e2e.js @@ -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); +})();