mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 19:39:11 +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>
110 lines
6.9 KiB
JavaScript
110 lines
6.9 KiB
JavaScript
#!/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);
|
|
})();
|