Files
meshcore-analyzer/public/node-reach-coverage.css
T
efitenandClaude Opus 5.5 f3004d977e 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>
2026-10-10 17:39:33 +02:00

46 lines
3.0 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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; }