/* === 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; 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 ''; } function pageHtml() { return '
' + '

🗺️ Mobile RX coverage

' + '
Where roaming CoreScope-RX clients heard nodes. Colour = best signal per cell. Get the companion app →
' + '
' + dayBtn(1) + dayBtn(7) + dayBtn(14) + dayBtn(30) + '
' + '
strongmediumweakno signal
' + '
' + '
Top mobile observers
' + '
' + '
'; } // coverageNodeRow renders one heard node: name (or heard_key prefix) + latest SNR + count. function coverageNodeRow(n) { var label = n.name ? escapeHtml(n.name) : '' + escapeHtml(n.prefix || '?') + ''; var snr = (n.snr != null) ? Number(n.snr).toFixed(1) + ' dB' : 'no sig'; return '
' + '' + label + '' + '' + snr + ' · ×' + n.count + '
'; } // 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 = '
' + nodes.length + (nodes.length === 1 ? ' node heard here' : ' nodes heard here') + '
'; if (!nodes.length) return head + '
n=' + (p ? p.count : 0) + '
'; var rows = nodes.slice(0, COVERAGE_NODE_CAP).map(coverageNodeRow).join(''); var more = (nodes.length > COVERAGE_NODE_CAP) ? '
(' + (nodes.length - COVERAGE_NODE_CAP) + ' more)
' : ''; return head + '
' + rows + '
' + 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; } } function drawCoverage() { if (!map || destroyed) return; var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { if (destroyed || !covLayer) return; covLayer.clearLayers(); (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); }); } // 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 '' + escapeHtml(c.label) + arrow + ''; }).join(''); return '
#' + cells + '
'; } function renderBoard() { var el = document.getElementById('rxBoard'); if (!el) return; if (!boardCache.length) { el.innerHTML = '
No mobile observers in this window yet.
'; return; } sortBoard(); var rows = boardCache.map(function (o, i) { var nm = o.name ? escapeHtml(o.name) : (escapeHtml(o.pubkey.slice(0, 10)) + '…'); return '
' + '' + (i + 1) + '' + nm + '' + '' + Number(o.score).toFixed(1) + '' + '' + o.cells + '' + '' + o.nodes + '' + '' + o.receptions + '
'; }).join(''); el.innerHTML = (selectedRx ? '' : '') + 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 = '
Could not load mobile observers.
'; }); } 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(); } function syncHash() { var q = 'days=' + days + (selectedRx ? '&rx=' + selectedRx : ''); try { history.replaceState(null, '', '#/rx-coverage?' + q); } catch (e) {} } function init(container) { destroyed = false; // 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) start(container); }); } function start(container) { if (!window.MC_CLIENT_RX_COVERAGE) { container.innerHTML = '
Coverage is not enabled on this deployment.
'; return; } selectedRx = ''; selectedName = ''; days = 7; boardCache = []; 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(); } } catch (e) {} container.innerHTML = pageHtml(); map = L.map('rxMap', { zoomControl: true, attributionControl: false }).setView([51.0, 4.8], 8); 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(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); }); setTimeout(function () { if (!destroyed && map) { map.invalidateSize(); if (selectedRx) fitToObserver(); else drawCoverage(); } }, 150); loadBoard(); } function destroy() { destroyed = true; if (map) { try { map.remove(); } catch (e) {} map = null; } covLayer = null; } registerPage('rx-coverage', { init: init, destroy: destroy }); })();