Files
meshcore-analyzer/public/rx-coverage.js
T
efitenandClaude Opus 5.5 c2a590b741 feat(companions): linked-only ingest, account Companions and My coverage (#2176)
Part 3 of 3 for #2173 (companion linking): the optional linked-only
ingest filter, the account page and the "My coverage" toggle, plus the
docs. Builds on #2174 and #2175 (both merged); `upstream/master` is
merged in, so the diff shows only this part.

## Changes in this PR

**Ingestor: linked-only ingest** (`cmd/ingestor/linked_companions.go`)
- With `clientRxCoverage.requireLinkedCompanion` on (default off) and
user management enabled, every `meshcore/client/<pubkey>/...` message
whose pubkey is not in `companion_links` is dropped before any client
handler runs, and counted (`client_unlinked_dropped` in the stats file).
- The ingestor opens `users.db` read-only (`mode=ro`, raw SQL, no
`internal/users` import), like the approved-channels reader, so the
#1283 write invariant holds. `TestLinkedCompanionSetIsReadOnly` pins it.
- The set refreshes every 60 s. A miss re-reads at most once per 5 s, so
a companion linked a moment ago is accepted within seconds while a flood
of unknown pubkeys costs one read per 5 s.
- It passes everything until its first successful read, and when
`users.db` has no `companion_links` table yet (a server older than
this). After the first read it keeps the last set on a read error. It is
a filter, not a security boundary: anyone with the shared broker
credential can still publish under a linked pubkey.

**Frontend**
- Account page: device tokens show under Devices as "CoreDrive RX" with
their label; a new Companions section lists linked companions (name,
short key, linked since, last seen) with an Unlink button.
- Coverage page (`#/rx-coverage`): a "My coverage" toggle for logged-in
users (`mine=1`). Only the newest coverage reply is drawn, and a refused
`mine=1` turns the toggle off. Under "My coverage" the page does not ask
for the "nothing received" cells from #2172, and their legend entry is
hidden.

**Docs**: user guide (accounts: devices, companions),
`docs/client-rx-coverage.md` (linking, linked-only ingest, the CORS
exception), `config.example.json`, and the spec status.

## Performance

The filter runs on every client message. Benchmarks with 1000 linked
companions (12-core dev box, `go test -bench`, 3 runs each):

| Path | Time | Allocations |
|---|---|---|
| linked pubkey (lock-free map lookup) | 54 ns/op | 0 |
| linked pubkey, parallel | 11-14 ns/op | 0 |
| unlinked pubkey inside the 5 s miss gap | 85-95 ns/op | 0 |
| one `users.db` re-read | 0.38 ms | 3030 |

The unlinked benchmark asserts that no extra `users.db` read happens. A
re-read happens at most once per 5 s on a miss and once a minute from
the refresh loop.

## Verification

- `cmd/ingestor`: 15 new tests (filter off changes nothing, linked
passes, unlinked dropped before every handler, miss refresh accepts a
new link, the 5 s cap holds under a flood, unlink honoured after the
periodic refresh, missing table and missing file, fail-closed only after
the first read, read-only open, startup, stats counter) and 4
benchmarks. Full ingestor suite green.
- Frontend unit tests for the account page (device tokens, companions
list escaping, empty state, Unlink confirm and cancel, load errors) and
`tests/unit/test-rx-coverage-mine.js`.
- E2E `tests/e2e/test-user-management-e2e.js` gains a companion step
(device token under Devices, a companion linked with a real Ed25519
signature under Companions, Unlink). Run locally against the e2etest
build: 28/28. The coverage suites (`test-rx-coverage-gaps-e2e.js`,
`-noise-`, `-viewport-`) still pass.
- Frontend unit suite (`test-all.sh`) green.

## Not in this PR

- Narrowing the "nothing received" cells to the caller's companions
under "My coverage" (the track query takes one companion today).
- Per-user broker credentials or ACLs, which would turn the filter into
an access control (non-goal in the spec).

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:38:27 +02:00

620 lines
32 KiB
JavaScript
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.
/* === CoreScope — rx-coverage.js ===
Mobile RX coverage hub (route #/rx-coverage):
- global H3-style hex coverage map (all mobile observers), time-windowed
- leaderboard of top mobile observers (companion name + counts)
- click an observer to filter the map to just their coverage
Fork-only feature; isolated page (no changes to the core map). */
'use strict';
(function () {
var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false, generation = 0;
// 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; }
// mine: "My coverage": the signal layer only shows coverage collected by
// companions linked to the logged-in user (/api/rx-coverage?mine=1).
var mine = false;
// signalSeq numbers signal-layer requests; only the newest reply is drawn.
var signalSeq = 0;
function cssColor(varName) {
try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; }
catch (e) { return '#888'; }
}
// SF8 SNR thresholds: ≥ −5 good margin, −9..−5 near the limit, < −9 packet loss
// likely. Grey = heard but no SNR metric.
function colorVar(p) {
if (!p || !p.has_sig || p.best_snr == null) return '--nq-cov-grey';
var s = Number(p.best_snr);
if (s >= -5) return '--nq-cov-strong';
if (s >= -9) return '--nq-cov-mid';
return '--nq-cov-weak';
}
function dayBtn(d) { return '<button data-days="' + d + '"' + (d === days ? ' class="active"' : '') + ' aria-pressed="' + (d === days ? 'true' : 'false') + '">' + (d === 1 ? '24h' : d + 'd') + '</button>'; }
function layerBtn(k, label) { return '<button data-layer="' + k + '"' + (k === layer ? ' class="active"' : '') + ' aria-pressed="' + (k === layer ? 'true' : 'false') + '">' + label + '</button>'; }
// mineBtnHtml renders the "My coverage" toggle, or nothing for a visitor
// who is not logged in (?mine=1 needs a session).
function mineBtnHtml(user, on) {
if (!user) return '';
return '<button data-mine="1"' + (on ? ' class="active"' : '') + ' aria-pressed="' + (on ? 'true' : 'false') + '"' +
' title="Only coverage collected by companions linked to your account">My coverage</button>';
}
// coverageUrl builds the signal-layer request.
function coverageUrl(bbox, z, d, rx, mineOn) {
return '/api/rx-coverage?bbox=' + bbox + '&z=' + z + '&days=' + d +
(rx ? '&rx=' + encodeURIComponent(rx) : '') + (mineOn ? '&mine=1' : '');
}
// coverageReply judges a signal-layer reply. Only the newest request counts
// (an older, unfiltered reply must not overwrite "My coverage"); a 401/403
// to mine=1 means the session is gone, which is not "no coverage".
function coverageReply(seq, latest, status, mineSent) {
if (seq !== latest) return 'ignore';
if (mineSent && (status === 401 || status === 403)) return 'mine-refused';
return status >= 200 && status < 300 ? 'draw' : 'error';
}
// authUser is the logged-in user, or null (accounts off, logged out, or
// auth.js not loaded).
function authUser(auth) {
return auth && auth.isEnabled && auth.isEnabled() && auth.user ? (auth.user() || null) : null;
}
// NOISE_QUIET_MAX / NOISE_BUSY_MIN (dBm) bucket the noise layer into 3 tiers.
// Fitted to observed data (#4): 1241 production moving samples
// (stationary=0) ranged -120..-88, mass concentrated -113..-116. Cumulative
// counts: <= -115 → 504 (41%), -114..-108 → 581 (47%), >= -107 → ~156 (12%).
// -115/-108 puts all three tiers within reach of that distribution instead
// of leaving "busy" almost never rendered.
var NOISE_QUIET_MAX = -115; // <= this: quiet
var NOISE_BUSY_MIN = -108; // > this: busy
// noiseColorVar reuses the coverage colour tokens (green=good, red=bad), but
// the axis is INVERTED versus colorVar's SNR check: for noise floor, a LOW
// (more negative) dBm reading is quieter/better, so it maps to the "strong"
// (green) token, and a HIGH reading maps to "weak" (red) -- the opposite
// comparison direction from colorVar.
function noiseColorVar(p) {
var m = Number(p.median_noise_floor);
if (m <= NOISE_QUIET_MAX) return '--nq-cov-strong';
if (m <= NOISE_BUSY_MIN) return '--nq-cov-mid';
return '--nq-cov-weak';
}
function legendHtml() {
if (layer === 'noise') {
return '<div class="nq-cov-legend" id="rxLegend">' +
'<span><i style="background:var(--nq-cov-strong)"></i>quiet (≤ ' + NOISE_QUIET_MAX + ' dBm)</span>' +
'<span><i style="background:var(--nq-cov-mid)"></i>medium</span>' +
'<span><i style="background:var(--nq-cov-weak)"></i>busy (> ' + NOISE_BUSY_MIN + ' dBm)</span>' +
'</div>';
}
return '<div class="nq-cov-legend" id="rxLegend">' +
'<span><i style="background:var(--nq-cov-strong)"></i>strong</span>' +
'<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 && !mine ? '<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.
function subtitleHtml() {
var colourText = layer === 'noise' ? 'Colour = median RF noise floor per cell.' : 'Colour = best signal per cell.';
return 'Where roaming CoreScope-RX clients heard nodes. ' + colourText + ' <a href="https://github.com/efiten/corescope-rx" target="_blank" rel="noopener">Get the companion app →</a>';
}
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="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>' +
'<div style="color:var(--text-muted);font-size:11px" id="rxSubtitle">' + subtitleHtml() + '</div>' +
'<div class="analytics-time-range" id="rxDays" style="margin:8px 0">' + dayBtn(1) + dayBtn(7) + dayBtn(14) + dayBtn(30) + '</div>' +
'<div class="analytics-time-range" id="rxMineBar" style="margin:8px 0" hidden></div>' +
layerBar +
legendHtml() +
'<div style="position:relative">' +
'<div id="rxMap" style="height:60vh;min-height:360px;border:1px solid var(--border,#d0d7de);border-radius:6px;margin:8px 0"></div>' +
'<div id="rxNoiseEmpty" class="nq-msg" style="display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:1000;pointer-events:none">No RF samples in this view yet.</div>' +
'</div>' +
'<div class="nq-group-h">Top mobile observers</div>' +
'<div id="rxBoard" class="rxb"></div>' +
'</div>';
}
// coverageNodeRow renders one heard node: name (or heard_key prefix) + latest SNR + count.
function coverageNodeRow(n) {
var label = n.name ? escapeHtml(n.name) : '<code>' + escapeHtml(n.prefix || '?') + '</code>';
var snr = (n.snr != null) ? Number(n.snr).toFixed(1) + ' dB' : 'no sig';
return '<div style="display:flex;gap:8px;justify-content:space-between;font-size:12px;line-height:1.6">' +
'<span>' + label + '</span>' +
'<span style="color:var(--text-muted)">' + snr + ' · ×' + n.count + '</span></div>';
}
// coverageNodesHtml lists the nodes directly heard in a cell (properties.nodes:
// {prefix, name, snr, count}, strongest latest-SNR first; prefix shown when the
// name is unresolved). Rendered in the hover tooltip; capped at 10 rows with a
// "(N more)" footer so dense cells don't produce an unwieldy tooltip.
var COVERAGE_NODE_CAP = 10;
function coverageNodesHtml(p) {
var nodes = (p && p.nodes) || [];
var head = '<div style="font-weight:600;margin-bottom:4px">' +
nodes.length + (nodes.length === 1 ? ' node heard here' : ' nodes heard here') + '</div>';
if (!nodes.length) return head + '<div style="color:var(--text-muted)">n=' + (p ? p.count : 0) + '</div>';
var rows = nodes.slice(0, COVERAGE_NODE_CAP).map(coverageNodeRow).join('');
var more = (nodes.length > COVERAGE_NODE_CAP)
? '<div style="color:var(--text-muted);font-size:11px;margin-top:3px">(' + (nodes.length - COVERAGE_NODE_CAP) + ' more)</div>'
: '';
return head + '<div style="min-width:180px">' + rows + '</div>' + more;
}
// fillOpacityFor adds a redundant, non-hue cue to the SNR tier so the map is
// distinguishable for colour-blind users (orange vs red): stronger signal =
// more opaque. Pairs with the hue and the per-cell SNR in the tooltip (#a11y).
function fillOpacityFor(p) {
switch (colorVar(p)) {
case '--nq-cov-strong': return 0.6;
case '--nq-cov-mid': return 0.48;
case '--nq-cov-weak': return 0.34;
default: return 0.22;
}
}
// noiseFillOpacityFor mirrors fillOpacityFor for the noise tier (#a11y).
function noiseFillOpacityFor(p) {
switch (noiseColorVar(p)) {
case '--nq-cov-strong': return 0.6;
case '--nq-cov-mid': return 0.48;
case '--nq-cov-weak': return 0.34;
default: return 0.22;
}
}
// noiseCellHtml renders the noise-layer tooltip: sample count, median, and
// the quietest/noisiest single sample in the cell.
function noiseCellHtml(p) {
if (!p) return '';
return '<div style="font-weight:600;margin-bottom:4px">' + p.count + (p.count === 1 ? ' sample' : ' samples') + '</div>' +
'<div style="font-size:12px;line-height:1.6;min-width:160px">' +
'<div>median: ' + Number(p.median_noise_floor).toFixed(1) + ' dBm</div>' +
'<div style="color:var(--text-muted)">quietest: ' + p.quietest_noise_floor + ' dBm · noisiest: ' + p.noisiest_noise_floor + ' dBm</div>' +
'</div>';
}
function drawSignalLayer(bbox) {
// Until auth.js has answered, mine is never sent (it would answer 401).
var mineSent = mine && !!authUser(window.CSAuth);
// Gaps need a single companion's track, so not with mine (the server omits them too).
var wantGaps = gapsAvailable() && showGaps && !mineSent;
var url = coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent) + (wantGaps ? '&gaps=1' : '');
var seq = ++signalSeq;
fetch(url).then(function (r) {
var verdict = coverageReply(seq, signalSeq, r.status, mineSent);
if (verdict === 'ignore') return null;
if (verdict === 'mine-refused') {
if (!destroyed && map) { mine = false; renderMine(); drawCoverage(); syncHash(); }
return null;
}
if (verdict === 'error') throw new Error('HTTP ' + r.status);
return r.json();
}).then(function (fc) {
if (!fc || 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));
L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: fillOpacityFor(f.properties) }).addTo(covLayer)
.bindTooltip(coverageNodesHtml(f.properties));
});
}).catch(function (e) {
console.warn('rx-coverage: coverage fetch failed', e);
// #1: never leave stale hexes from a previous layer/view on screen.
if (!destroyed && covLayer && layer === 'signal' && seq === signalSeq) covLayer.clearLayers();
});
}
function drawNoiseLayer(bbox) {
var url = '/api/rf-noise?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days;
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
if (destroyed || !covLayer || layer !== 'noise') return;
covLayer.clearLayers();
var features = fc.features || [];
setNoiseEmpty(features.length === 0);
features.forEach(function (f) {
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; });
var col = cssColor(noiseColorVar(f.properties));
L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: noiseFillOpacityFor(f.properties) }).addTo(covLayer)
.bindTooltip(noiseCellHtml(f.properties));
});
}).catch(function (e) {
console.warn('rx-coverage: noise fetch failed', e);
// #1/#2: a slow/failing request must not leave the map showing the
// other layer's hexes under the noise legend, nor sit there blank and
// unlabelled indistinguishable from "no samples in this view".
if (destroyed || !covLayer || layer !== 'noise') return;
covLayer.clearLayers();
setNoiseEmpty(true, 'Could not load noise samples. Pan or zoom to retry.');
});
}
// setNoiseEmpty toggles the "no samples in this view" message. Only shown
// on the noise layer — MC_CLIENT_RF_SAMPLES already keeps the toggle (and
// so this layer) out of reach entirely when the feature is off, so seeing
// this message always means "on", never "feature disabled" (the two states
// this project keeps needing to keep distinct). `text` overrides the
// default no-samples copy for the fetch-failed case (#2), which must read
// as distinctly different — and retryable — from "no samples here yet".
function setNoiseEmpty(show, text) {
var el = document.getElementById('rxNoiseEmpty');
if (!el) return;
if (show && layer === 'noise') {
el.textContent = text || 'No RF samples in this view yet.';
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
function drawCoverage() {
if (!map || destroyed) return;
var b = map.getBounds();
var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(',');
if (layer === 'noise') drawNoiseLayer(bbox);
else drawSignalLayer(bbox);
}
// Leaderboard sort state. Default = frontier score, descending. The rank (#)
// column is not sortable (it just reflects the current order). Numeric columns
// default to descending on first click; the name column to ascending.
var boardSort = { key: 'score', dir: 'desc' };
var BOARD_COLS = [
{ key: 'name', label: 'Observer (companion)', cls: 'rxb-name' },
{ key: 'score', label: 'score', cls: 'rxb-score',
title: 'Score telt je gedekte cellen, waarbij elke cel zwaarder weegt naarmate minder andere waarnemers ze bereikt hebben — grensverleggende dekking weegt meer dan drukke zones opnieuw afrijden.' },
{ key: 'cells', label: 'cells', cls: 'rxb-cells',
title: 'Aantal unieke ~150 m-cellen waar deze waarnemer iets hoorde.' },
{ key: 'nodes', label: 'nodes', cls: 'rxb-nodes' },
{ key: 'receptions', label: 'pkts', cls: 'rxb-rec' }
];
function sortBoard() {
var k = boardSort.key, dir = boardSort.dir === 'asc' ? 1 : -1;
boardCache.sort(function (a, b) {
if (k === 'name') {
var an = (a.name || a.pubkey).toLowerCase(), bn = (b.name || b.pubkey).toLowerCase();
return an < bn ? -dir : an > bn ? dir : 0;
}
return (Number(a[k]) - Number(b[k])) * dir;
});
}
function boardHeadHtml() {
var cells = BOARD_COLS.map(function (c) {
var arrow = boardSort.key === c.key ? (boardSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
return '<span class="' + c.cls + ' rxb-sort" data-sort="' + c.key + '"' +
(c.title ? ' title="' + escapeHtml(c.title) + '"' : '') +
' role="button" tabindex="0">' + escapeHtml(c.label) + arrow + '</span>';
}).join('');
return '<div class="rxb-row rxb-head"><span class="rxb-rank">#</span>' + cells + '</div>';
}
function renderBoard() {
var el = document.getElementById('rxBoard');
if (!el) return;
if (!boardCache.length) { el.innerHTML = '<div class="muted" style="color:var(--text-muted);font-size:13px">No mobile observers in this window yet.</div>'; return; }
sortBoard();
var rows = boardCache.map(function (o, i) {
var nm = o.name ? escapeHtml(o.name) : (escapeHtml(o.pubkey.slice(0, 10)) + '…');
return '<div class="rxb-row' + (o.pubkey === selectedRx ? ' sel' : '') + '" data-rx="' + escapeHtml(o.pubkey) + '" data-name="' + escapeHtml(o.name || '') + '"' +
' role="button" tabindex="0" aria-pressed="' + (o.pubkey === selectedRx ? 'true' : 'false') + '" aria-label="Show coverage for ' + escapeHtml(o.name || o.pubkey.slice(0, 10)) + '">' +
'<span class="rxb-rank">' + (i + 1) + '</span><span class="rxb-name">' + nm + '</span>' +
'<span class="rxb-score">' + Number(o.score).toFixed(1) + '</span>' +
'<span class="rxb-cells">' + o.cells + '</span>' +
'<span class="rxb-nodes">' + o.nodes + '</span>' +
'<span class="rxb-rec">' + o.receptions + '</span></div>';
}).join('');
el.innerHTML = (selectedRx ? '<button id="rxAll" class="btn-primary" style="margin:0 0 8px">← Show all observers</button>' : '') +
boardHeadHtml() + rows;
// Column sort handlers (click + keyboard).
el.querySelectorAll('.rxb-sort[data-sort]').forEach(function (h) {
function applySort() {
var k = h.dataset.sort;
if (boardSort.key === k) {
boardSort.dir = boardSort.dir === 'asc' ? 'desc' : 'asc';
} else {
boardSort.key = k;
boardSort.dir = (k === 'name') ? 'asc' : 'desc';
}
renderBoard();
}
h.addEventListener('click', applySort);
h.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); applySort(); }
});
});
// Row click-to-filter (preserved from the original).
el.querySelectorAll('.rxb-row[data-rx]').forEach(function (r) {
function activate() {
selectedRx = r.dataset.rx; selectedName = r.dataset.name || '';
renderBoard(); fitToObserver(); syncHash();
}
r.addEventListener('click', activate);
r.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); activate(); }
});
});
var all = document.getElementById('rxAll');
if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; renderBoard(); drawCoverage(); syncHash(); });
}
// fitToObserver zooms the map to the selected observer's full coverage extent
// (fetched with a world bbox so it's independent of the current view), then the
// resulting moveend redraws the hexes at the fitted resolution.
function fitToObserver() {
if (!map || !selectedRx) { drawCoverage(); return; }
var url = '/api/rx-coverage?bbox=-90,-180,90,180&z=' + Math.max(8, map.getZoom()) + '&days=' + days + '&rx=' + encodeURIComponent(selectedRx);
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
if (destroyed || !map) return;
var minLat = 90, minLon = 180, maxLat = -90, maxLon = -180, any = false;
(fc.features || []).forEach(function (f) {
(f.geometry.coordinates[0] || []).forEach(function (c) {
any = true;
if (c[1] < minLat) minLat = c[1]; if (c[1] > maxLat) maxLat = c[1];
if (c[0] < minLon) minLon = c[0]; if (c[0] > maxLon) maxLon = c[0];
});
});
if (!any) { drawCoverage(); return; } // observer has no data in window → keep view
map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [30, 30], maxZoom: 15 });
drawCoverage(); // fitBounds may not fire moveend if the view is unchanged
}).catch(function (e) { console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage(); });
}
function loadBoard() {
fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); })
.then(function (d) { if (destroyed) return; boardCache = d.observers || []; renderBoard(); })
.catch(function (e) {
console.warn('rx-coverage: leaderboard fetch failed', e);
var el = document.getElementById('rxBoard');
if (el) el.innerHTML = '<div class="muted" style="color:var(--text-muted);font-size:13px">Could not load mobile observers.</div>';
});
}
function setDays(d) {
days = d;
var bar = document.getElementById('rxDays');
if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', +b.dataset.days === d); });
loadBoard(); drawCoverage(); syncHash();
}
// setLayer switches between the 'signal' (SNR) and 'noise' hex layers. Only
// reachable when MC_CLIENT_RF_SAMPLES rendered the toggle in the first place.
function setLayer(l) {
if (l === layer) return;
layer = l;
// Clear the old layer's polygons synchronously (#1): the legend swap
// below is synchronous too, so a slow/failed fetch must never leave the
// previous layer's hexes on screen under the new layer's legend/tooltips.
if (covLayer) covLayer.clearLayers();
var bar = document.getElementById('rxLayerBar');
if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', b.dataset.layer === l); });
var legend = document.getElementById('rxLegend');
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 : '') + (mine ? '&mine=1' : '') + (gapsAvailable() && !showGaps ? '&gaps=0' : '');
if (map) {
var c = map.getCenter();
q += '&lat=' + c.lat.toFixed(5) + '&lon=' + c.lng.toFixed(5) + '&zoom=' + map.getZoom();
}
try { history.replaceState(null, '', '#/rx-coverage?' + q); } catch (e) {}
}
function init(container) {
destroyed = false;
var current = ++generation;
// A direct land on #/rx-coverage can run before MeshConfigReady resolves, at
// which point MC_CLIENT_RX_COVERAGE is still undefined and the page would
// wrongly show "not enabled". Defer until server config is loaded (#13).
Promise.resolve(window.MeshConfigReady).then(function () {
if (!destroyed && current === generation) start(container, current);
});
}
async function start(container, current) {
if (!window.MC_CLIENT_RX_COVERAGE) {
container.innerHTML = '<div class="nq-msg">Coverage is not enabled on this deployment.</div>';
return;
}
selectedRx = ''; selectedName = ''; days = 7; boardCache = []; layer = 'signal'; mine = false; 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;
mine = p.get('mine') === '1';
}
} catch (e) {}
container.innerHTML = pageHtml();
// Initialize viewport: explicit URL hash, coverage page's saved position, deployment defaults (#2032).
var viewport = parseViewportHash(location.hash);
var explicitViewport = !!viewport;
if (!viewport) {
try {
var saved = JSON.parse(localStorage.getItem('rx-coverage-view'));
if (saved && saved.lat != null && saved.lng != null && saved.zoom != null) {
viewport = parseViewportHash(new URLSearchParams({ lat: saved.lat, lon: saved.lng, zoom: saved.zoom }).toString());
}
} catch (e) {} // Storage may be disabled or contain an incomplete value.
}
if (!viewport) {
viewport = { lat: 37.6, lon: -122.1, zoom: 9 };
try {
var response = await fetch('/api/config/map');
var cfg = await response.json();
if (cfg && Array.isArray(cfg.center) && cfg.center.length === 2) {
viewport = parseViewportHash(new URLSearchParams({ lat: cfg.center[0], lon: cfg.center[1], zoom: cfg.zoom == null ? 9 : cfg.zoom }).toString()) || viewport;
}
} catch (e) {} // Match the main map's offline fallback.
}
if (destroyed || current !== generation) return;
map = L.map('rxMap', { zoomControl: true, attributionControl: false }).setView([viewport.lat, viewport.lon], viewport.zoom);
if (typeof window._applyTilesToNodeMap === 'function') window._applyTilesToNodeMap(map);
else L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
covLayer = L.layerGroup().addTo(map);
// Debounce pan/zoom redraws so dragging the map doesn't fire a storm of
// /api/rx-coverage requests (#6). Direct calls (setDays, fit) stay immediate.
map.on('moveend zoomend', debounce(function () {
if (destroyed || current !== generation || !map) return;
var center = map.getCenter();
try { localStorage.setItem('rx-coverage-view', JSON.stringify({ lat: center.lat, lng: center.lng, zoom: map.getZoom() })); } catch (e) {}
syncHash();
drawCoverage();
}, 200));
var bar = document.getElementById('rxDays');
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); });
var mineBar = document.getElementById('rxMineBar');
if (mineBar) mineBar.addEventListener('click', function (e) { var b = e.target.closest('button[data-mine]'); if (b) setMine(!mine); });
// The toggle waits for auth.js's first /api/auth/me; a mine=1 link from a
// logged-in user then redraws with the filter.
Promise.resolve(window.CSAuth && window.CSAuth.ready ? window.CSAuth.ready() : null).then(function () {
if (destroyed || current !== generation) return;
renderMine();
if (mine && map) drawCoverage();
});
setTimeout(function () { if (!destroyed && current === generation && map) { map.invalidateSize(); if (selectedRx && !explicitViewport) fitToObserver(); else drawCoverage(); } }, 150);
loadBoard();
}
// renderMine shows the toggle to a logged-in user only; without one the
// toggle is off (a mine=1 link opened while logged out is ignored).
function renderMine() {
var user = authUser(window.CSAuth);
if (!user) mine = false;
var bar = document.getElementById('rxMineBar');
if (!bar) return;
bar.innerHTML = mineBtnHtml(user, mine);
bar.hidden = !user;
}
function setMine(on) {
if (on === mine) return;
mine = on;
renderMine();
var legend = document.getElementById('rxLegend');
if (legend) legend.outerHTML = legendHtml();
drawCoverage(); syncHash();
}
// Logging in or out while the page is open shows or hides the toggle; a
// logout with the toggle on redraws everyone's coverage.
function onAuthChanged() {
if (destroyed || !map) return;
var was = mine;
renderMine();
if (was !== mine) { drawCoverage(); syncHash(); }
}
function destroy() {
destroyed = true;
generation++;
if (map) { try { map.remove(); } catch (e) {} map = null; }
covLayer = null;
gapRenderer = null;
}
if (window.addEventListener) window.addEventListener('cs-auth-changed', onAuthChanged);
window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply } };
registerPage('rx-coverage', { init: init, destroy: destroy });
})();