Files
meshcore-analyzer/public/rx-coverage.js
T
efitenandClaude Opus 5.5 bb3c8cc001 fix(rx-coverage): My coverage and the observer filter on the noise layer (#2326)
"My coverage" and a selected observer only narrowed the signal layer on
the coverage page. On the noise layer the toggle stayed pressed, but
`/api/rf-noise` was called without `mine` or `rx` and returned every
companion's samples.

## Changes

- **Backend:** `/api/rf-noise` now accepts `rx` and `mine=1`, with the
same session rules as `/api/rx-coverage`: 401 without a session, 403 for
a device token, 404 with user management off. The `rx_pubkey` filter
moved into 1 helper, `appendRxFilter` (`cmd/server/rx_dashboard.go`),
which both queries use. Both parameters are in the OpenAPI entry.
- **Frontend:** `public/rx-coverage.js` fetches both layers through
`fetchLayer`. It builds the URL with the time window, observer and
toggle, and judges the reply with the existing `coverageReply`. The
request sequence (`signalSeq`, now `drawSeq`) covers both layers, so the
noise layer also drops a superseded reply. Before, a slow unfiltered
noise reply could overwrite a newer filtered one.

## Performance

The noise query gets 1 extra `WHERE` clause on `rx_pubkey`. There are no
new requests: the page still makes 1 request per redraw.

## Verification

- New Go tests in `cmd/server/rf_noise_mine_test.go`: `rx` narrows the
result (case-insensitive), `mine=1` returns 401 without a session,
returns empty with nothing linked, returns the linked companion's cells,
returns 403 for a device token, and returns 404 with user management
off. Both failed before the fix.
- `tests/unit/test-rx-coverage-mine.js` is updated for the new
`coverageUrl(path, ...)` signature and checks that both layers fetch
through `fetchLayer`.
- New step in `tests/e2e/test-rx-coverage-noise-e2e.js`: a
`?layer=noise&rx=...` link sends `rx`, and clicking "My coverage"
refetches with `mine=1`. It fails against the current master frontend
and passes with this change.
- Local runs (Windows): `go test` and `go vet` in `cmd/server`,
`test-all.sh`, and the noise, gaps and viewport E2E in Chrome. All
green. I did not run the full Playwright suite locally.

## Not in this PR

- The "nothing received" gap cells still skip `mine=1`, unchanged: the
track query cannot narrow to a set of companions yet.
- The time window (`days`) already worked on the noise layer, so it is
not touched.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:39:02 +01:00

635 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": every layer only shows data collected by companions
// linked to the logged-in user (?mine=1).
var mine = false;
// drawSeq numbers layer requests; only the newest reply is drawn.
var drawSeq = 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 a layer request (path = the layer's endpoint) with the
// shared filters: time window, selected observer and "My coverage".
function coverageUrl(path, bbox, z, d, rx, mineOn) {
return path + '?bbox=' + bbox + '&z=' + z + '&days=' + d +
(rx ? '&rx=' + encodeURIComponent(rx) : '') + (mineOn ? '&mine=1' : '');
}
// coverageReply judges a 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';
}
// freshBody resolves to the parsed body, or to null when a newer request
// started while it was still arriving: coverageReply only saw the headers.
function freshBody(seq, latest, body) {
return body.then(function (fc) { return seq === latest() ? fc : null; });
}
// 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>';
}
// mineSent: until auth.js has answered, mine is never sent (it would answer 401).
function mineSent() { return mine && !!authUser(window.CSAuth); }
// fetchLayer requests one layer's cells with the shared filters. It resolves
// to the FeatureCollection, or to null when request seq was superseded or
// mine=1 was refused (the toggle is then cleared and the map redrawn).
function fetchLayer(path, bbox, extra, seq) {
var sentMine = mineSent();
return fetch(coverageUrl(path, bbox, map.getZoom(), days, selectedRx, sentMine) + extra).then(function (r) {
var verdict = coverageReply(seq, drawSeq, r.status, sentMine);
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 freshBody(seq, function () { return drawSeq; }, r.json());
});
}
function drawSignalLayer(bbox) {
// Gaps need a single companion's track, so not with mine (the server omits them too).
var wantGaps = gapsAvailable() && showGaps && !mineSent();
var seq = ++drawSeq;
fetchLayer('/api/rx-coverage', bbox, wantGaps ? '&gaps=1' : '', seq).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 === drawSeq) covLayer.clearLayers();
});
}
function drawNoiseLayer(bbox) {
var seq = ++drawSeq;
fetchLayer('/api/rf-noise', bbox, '', seq).then(function (fc) {
if (!fc || 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' || seq !== drawSeq) 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: 'Each ~150 m cell this observer heard something in counts 1 divided by the number of observers that heard something there: a cell nobody else reached counts 1, a cell shared with 3 others counts 0.25. New ground scores more than driving busy areas again.' },
{ key: 'cells', label: 'cells', cls: 'rxb-cells',
title: 'Unique ~150 m cells where this observer heard something.' },
{ 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, freshBody: freshBody } };
registerPage('rx-coverage', { init: init, destroy: destroy });
})();