mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 21:17:22 +00:00
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>
46 lines
3.0 KiB
CSS
46 lines
3.0 KiB
CSS
/* Client-RX coverage styles (fork-only feature). Kept in a DEDICATED file —
|
||
separate from node-reach.css — so upstream's periodic node-reach.css rewrites
|
||
don't drop the coverage tier colours + leaderboard layout (as happened in the
|
||
v3.9.0 merge). Consumed by rx-coverage.js (standalone Coverage dashboard) and,
|
||
once re-grafted, the per-node Reach page coverage overlay. */
|
||
|
||
/* RX coverage hex layer (mobile client receptions) */
|
||
:root {
|
||
--nq-cov-strong: #2ecc71; /* SF8: SNR ≥ −5 dB (good margin) */
|
||
--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). */
|
||
[data-theme="dark"] {
|
||
--nq-cov-strong: #3fb950;
|
||
--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; }
|
||
.rxb-row { display:flex; align-items:center; gap:10px; padding:7px 10px; background:var(--section-bg, #f6f8fa); border:1px solid var(--border, #d0d7de); border-radius:6px; font-size:13px; cursor:pointer; }
|
||
.rxb-row.rxb-head { font-size:10px; text-transform:uppercase; color:var(--text-muted, #57606a); cursor:default; }
|
||
.rxb-row.sel { outline:2px solid var(--accent, #2ecc71); }
|
||
/* Visible keyboard focus for the now-focusable (#polish) leaderboard rows. */
|
||
.rxb-row[data-rx]:focus-visible { outline:2px solid var(--link, #0969da); outline-offset:1px; }
|
||
.rxb-rank { width:24px; text-align:right; color:var(--text-muted, #57606a); }
|
||
.rxb-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.rxb-rec, .rxb-nodes, .rxb-cells, .rxb-score { width:56px; text-align:right; font-variant-numeric:tabular-nums; }
|
||
/* Sortable column headers in the leaderboard head row. */
|
||
.rxb-head .rxb-sort { cursor:pointer; user-select:none; }
|
||
.rxb-head .rxb-sort:hover { color:var(--link, #0969da); }
|
||
.rxb-head .rxb-sort:focus-visible { outline:2px solid var(--link, #0969da); outline-offset:1px; }
|