diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index bd522b19..15b4675c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -577,6 +577,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-node-reach-coverage-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-issue-1640-compare-discovery-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 node tests/e2e/test-neighbor-map-btn-clip-e2e.js 2>&1 | tee -a e2e-output.txt
+ BASE_URL=http://localhost:13581 node tests/e2e/test-rx-coverage-viewport-e2e.js 2>&1 | tee -a e2e-output.txt
# #1616: slide-over focus-restore flake-gate. Runs the slide-over
# E2E 20 consecutive times against the SAME backend instance so
diff --git a/public/rx-coverage.js b/public/rx-coverage.js
index a8f1b2c0..1d1b12ad 100644
--- a/public/rx-coverage.js
+++ b/public/rx-coverage.js
@@ -6,7 +6,7 @@
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;
+ var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false, generation = 0;
function cssColor(varName) {
try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; }
@@ -215,21 +215,26 @@
}
function syncHash() {
- var q = 'days=' + days + (selectedRx ? '&rx=' + selectedRx : '');
+ var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '');
+ 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) start(container);
+ if (!destroyed && current === generation) start(container, current);
});
}
- function start(container) {
+ async function start(container, current) {
if (!window.MC_CLIENT_RX_COVERAGE) {
container.innerHTML = '
Coverage is not enabled on this deployment.
';
return;
@@ -240,21 +245,50 @@
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);
+ // 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(drawCoverage, 200));
+ 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); });
- setTimeout(function () { if (!destroyed && map) { map.invalidateSize(); if (selectedRx) fitToObserver(); else drawCoverage(); } }, 150);
+ setTimeout(function () { if (!destroyed && current === generation && map) { map.invalidateSize(); if (selectedRx && !explicitViewport) fitToObserver(); else drawCoverage(); } }, 150);
loadBoard();
}
function destroy() {
destroyed = true;
+ generation++;
if (map) { try { map.remove(); } catch (e) {} map = null; }
covLayer = null;
}
diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json
index 3d7c59fc..8c541d80 100644
--- a/scripts/non-unit-tests.json
+++ b/scripts/non-unit-tests.json
@@ -104,6 +104,7 @@
"tests/e2e/test-packets-scope-column.js",
"tests/e2e/test-path-inspector-coverage-e2e.js",
"tests/e2e/test-rx-coverage-mobile-nav-e2e.js",
+ "tests/e2e/test-rx-coverage-viewport-e2e.js",
"tests/e2e/test-show-neighbors.js",
"tests/e2e/test-slideover-1056-e2e.js",
"tests/e2e/test-slideover-1168-munger-e2e.js",
diff --git a/test-all.sh b/test-all.sh
index 3d6d5245..02165abf 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -174,6 +174,7 @@ node tests/unit/test-pull-to-reconnect.js
node tests/unit/test-repeater-metric-scatter.js
node tests/unit/test-rx-coverage-config-race.js
node tests/unit/test-rx-coverage-escape.js
+node tests/unit/test-rx-coverage-viewport.js
node tests/unit/test-scope-audit-styles-linked.js
node tests/unit/test-slideover-1056-rowsel-strict.js
node tests/unit/test-top-routes-overlay.js
diff --git a/tests/e2e/test-rx-coverage-viewport-e2e.js b/tests/e2e/test-rx-coverage-viewport-e2e.js
new file mode 100644
index 00000000..52baec1e
--- /dev/null
+++ b/tests/e2e/test-rx-coverage-viewport-e2e.js
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+'use strict';
+// #2032: real Leaflet viewport initialization, persistence and observer links.
+const assert = require('assert');
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:3000';
+(async () => {
+ const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
+ try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.addInitScript(() => {
+ let leaflet;
+ Object.defineProperty(window, 'L', {
+ configurable: true,
+ get: () => leaflet,
+ set(value) {
+ leaflet = value;
+ value.Map.addInitHook(function () { window.__rxTestMap = this; });
+ }
+ });
+ });
+ await page.route('**/api/config/client', async route => {
+ const response = await route.fetch();
+ await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true } });
+ });
+ await page.route('**/api/config/map', route => route.fulfill({ json: { center: [12, 34], zoom: 6 } }));
+ await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } }));
+ let worldRequests = 0;
+ const observerCell = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[[40, 10], [41, 10], [41, 11], [40, 11], [40, 10]]] }, properties: { count: 1, has_sig: false, nodes: [] } };
+ await page.route('**/api/rx-coverage?*', route => {
+ if (new URL(route.request().url()).searchParams.get('bbox') === '-90,-180,90,180') worldRequests++;
+ return route.fulfill({ json: { type: 'FeatureCollection', features: [observerCell] } });
+ });
+ async function waitMap() {
+ await page.waitForFunction(() => window.__rxTestMap && window.__rxTestMap._loaded && document.querySelector('#rxMap.leaflet-container'));
+ }
+ async function viewport() {
+ return page.evaluate(() => { const m = window.__rxTestMap, c = m.getCenter(); return { lat: c.lat, lng: c.lng, zoom: m.getZoom() }; });
+ }
+ function assertViewport(actual, expected) {
+ // Leaflet projects centers to pixels during invalidateSize/pan, so allow
+ // sub-pixel coordinate rounding while still checking the intended view.
+ assert(Math.abs(actual.lat - expected.lat) < 0.001, JSON.stringify(actual));
+ assert(Math.abs(actual.lng - expected.lng) < 0.001, JSON.stringify(actual));
+ assert.equal(actual.zoom, expected.zoom);
+ }
+ await page.goto(BASE + '/#/rx-coverage'); await waitMap();
+ assertViewport(await viewport(), { lat: 12, lng: 34, zoom: 6 });
+ await page.evaluate(() => window.__rxTestMap.setView([22, 44], 10, { animate: false }));
+ await page.waitForFunction(() => Math.abs(Number(new URLSearchParams(location.hash.split('?')[1]).get('lat')) - 22) < 0.001);
+ assertViewport(await page.evaluate(() => JSON.parse(localStorage.getItem('rx-coverage-view'))), { lat: 22, lng: 44, zoom: 10 });
+ assert.equal(await page.evaluate(() => localStorage.getItem('map-view')), null, 'coverage must not write the main map\'s saved viewport');
+ await page.reload(); await waitMap(); assertViewport(await viewport(), { lat: 22, lng: 44, zoom: 10 });
+ // Remove the shareable URL to independently verify this page's saved state.
+ await page.goto(BASE + '/#/rx-coverage'); await page.reload(); await waitMap();
+ assertViewport(await viewport(), { lat: 22, lng: 44, zoom: 10 });
+ await page.goto(BASE + '/#/rx-coverage?days=14&rx=abcd&lat=0&lon=0&zoom=5'); await page.reload(); await waitMap();
+ await page.waitForTimeout(500);
+ assertViewport(await viewport(), { lat: 0, lng: 0, zoom: 5 });
+ assert.equal(worldRequests, 0, 'explicit observer viewport must not auto-fit');
+ await page.click('#rxDays button[data-days="30"]');
+ const params = new URLSearchParams(new URL(page.url()).hash.split('?')[1]);
+ assert.equal(params.get('days'), '30'); assert.equal(params.get('rx'), 'abcd'); assert.equal(params.get('lat'), '0.00000');
+ // Observer-only links still fit the observer's complete extent.
+ await page.goto(BASE + '/#/rx-coverage?rx=abcd'); await page.reload(); await waitMap();
+ await page.waitForFunction(() => {
+ const c = window.__rxTestMap.getCenter();
+ return c.lat > 10 && c.lat < 11 && c.lng > 40 && c.lng < 41;
+ });
+ assert(worldRequests > 0, 'observer-only links must request full extent');
+ if (process.env.SCREENSHOT_PATH) await page.screenshot({ path: process.env.SCREENSHOT_PATH, fullPage: true });
+ assert.deepStrictEqual(errors, []);
+ console.log('RX coverage viewport browser regressions OK');
+ } finally { await browser.close(); }
+})().catch(error => { console.error(error); process.exit(1); });
diff --git a/tests/unit/test-rx-coverage-viewport.js b/tests/unit/test-rx-coverage-viewport.js
new file mode 100644
index 00000000..7014f420
--- /dev/null
+++ b/tests/unit/test-rx-coverage-viewport.js
@@ -0,0 +1,95 @@
+'use strict';
+const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
+// #2032: exercise the registered RX page, with the real shared URL parser.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const source = fs.readFileSync(path.join(REPO_ROOT, 'public', 'rx-coverage.js'), 'utf8');
+const app = fs.readFileSync(path.join(REPO_ROOT, 'public', 'app.js'), 'utf8');
+const parser = app.slice(app.indexOf('function parseViewportHash('), app.indexOf("if (typeof window !== 'undefined') { window.parseViewportHash"));
+const flush = () => new Promise(resolve => setImmediate(resolve));
+function harness(options = {}) {
+ let page;
+ const maps = [], requests = [], timers = [];
+ const storage = { 'rx-coverage-view': options.saved };
+ const location = { hash: options.hash || '#/rx-coverage' };
+ const context = {
+ URLSearchParams, location, Promise, console,
+ window: { MC_CLIENT_RX_COVERAGE: true, MeshConfigReady: options.ready },
+ document: { getElementById: () => null },
+ registerPage: (_, value) => { page = value; },
+ getHashParams: () => new URLSearchParams(location.hash.split('?')[1]),
+ localStorage: {
+ getItem: key => { if (options.blockStorage) throw Error('blocked'); return storage[key]; },
+ setItem: (key, value) => { if (options.blockStorage) throw Error('blocked'); storage[key] = value; }
+ },
+ history: { replaceState: (_, __, hash) => { location.hash = hash; } },
+ debounce: fn => fn,
+ setTimeout: fn => { timers.push(fn); }, clearTimeout: () => {},
+ fetch: url => {
+ requests.push(url);
+ if (url === '/api/config/map') {
+ if (options.configPromise) return options.configPromise;
+ if (options.failConfig) return Promise.reject(Error('offline'));
+ return Promise.resolve({ json: () => options.config || { center: [12, 34], zoom: 6 } });
+ }
+ return Promise.resolve({ json: () => ({ features: [], observers: [] }) });
+ },
+ L: {
+ map: () => {
+ const m = { events: {}, setView(center, zoom) { this.center = Array.from(center); this.zoom = zoom; return this; },
+ on(events, fn) { events.split(' ').forEach(event => { this.events[event] = fn; }); },
+ getCenter() { return { lat: this.center[0], lng: this.center[1] }; }, getZoom() { return this.zoom; },
+ getBounds() { return { getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 }; },
+ invalidateSize() {}, remove() { this.removed = true; } };
+ maps.push(m); return m;
+ },
+ tileLayer: () => ({ addTo() {} }), layerGroup: () => ({ addTo() { return this; }, clearLayers() {} })
+ }
+ };
+ vm.runInNewContext(parser + '\n' + source, context);
+ return { page, maps, requests, timers, storage, location, init: async () => { page.init({ innerHTML: '' }); await flush(); } };
+}
+(async () => {
+ let h = harness(); await h.init(); assert.deepStrictEqual(h.maps[0].center, [12, 34]); assert.equal(h.maps[0].zoom, 6); assert.equal(h.requests.filter(url => url === '/api/config/map').length, 1);
+ h = harness({ saved: JSON.stringify({ lat: 22, lng: 44, zoom: 11 }) }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [22, 44]); assert.equal(h.maps[0].zoom, 11); assert(!h.requests.includes('/api/config/map'));
+ h = harness({ hash: '#/rx-coverage?days=14&rx=abcd&lat=0&lon=0&zoom=5', saved: JSON.stringify({ lat: 22, lng: 44, zoom: 11 }) });
+ await h.init(); assert.deepStrictEqual(h.maps[0].center, [0, 0]); assert(!h.requests.includes('/api/config/map')); h.timers.forEach(fn => fn()); await flush();
+ assert(!h.requests.some(url => url.includes('bbox=-90,-180')), 'explicit URL viewport must not be fitted to observer');
+ h.maps[0].setView([40, 50], 10); h.maps[0].events.moveend();
+ assert.deepStrictEqual(JSON.parse(h.storage['rx-coverage-view']), { lat: 40, lng: 50, zoom: 10 });
+ assert.equal(h.storage['map-view'], undefined, 'coverage must not write the main map\'s saved viewport');
+ const params = new URLSearchParams(h.location.hash.split('?')[1]);
+ for (const [key, value] of Object.entries({ days: '14', rx: 'abcd', lat: '40.00000', lon: '50.00000', zoom: '10' })) assert.equal(params.get(key), value);
+ for (const saved of ['broken', 'null', '{}', '{"lat":12,"lng":34,"zoom":"invalid"}', '{"lat":null,"lng":4,"zoom":8}', '{"lat":91,"lng":4,"zoom":8}', '{"lat":"nope","lng":4,"zoom":8}']) {
+ h = harness({ saved }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [12, 34], saved);
+ }
+ h = harness({ saved: JSON.stringify({ lat: 0, lng: 0, zoom: 4 }) }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [0, 0]);
+ h = harness({ hash: '#/rx-coverage?lat=bad&lon=34' }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [12, 34]);
+ for (const center of [[999, 34], null, [null, 34], ['bad', 34], []]) {
+ h = harness({ config: { center, zoom: 6 } }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [37.6, -122.1]);
+ }
+ h = harness({ blockStorage: true }); await h.init(); h.maps[0].events.moveend();
+ h = harness({ failConfig: true }); await h.init(); assert.deepStrictEqual(h.maps[0].center, [37.6, -122.1]); assert.equal(h.maps[0].zoom, 9);
+ h = harness({ hash: '#/rx-coverage?rx=abcd' }); await h.init(); h.timers.forEach(fn => fn()); await flush(); assert(h.requests.some(url => url.includes('bbox=-90,-180')));
+ let resolve;
+ h = harness({ configPromise: new Promise(r => { resolve = r; }) });
+ await h.init(); h.page.destroy(); resolve({ json: () => ({ center: [12, 34], zoom: 6 }) }); await flush(); assert.equal(h.maps.length, 0, 'late config must not recreate a destroyed map');
+ let delayed;
+ h = harness({ configPromise: new Promise(r => { delayed = r; }) });
+ await h.init(); h.page.destroy(); h.page.init({ innerHTML: '' }); await flush();
+ delayed({ json: () => ({ center: [12, 34], zoom: 6 }) }); await flush();
+ assert.equal(h.maps.length, 1, 'old config response must not create a map after re-entry');
+ // A stale 150 ms layout timer must not fetch for the next visit.
+ const oldMove = h.maps[0].events.moveend;
+ const oldTimer = h.timers[0]; h.page.destroy(); h.page.init({ innerHTML: '' }); await flush();
+ const before = h.requests.length; oldTimer(); assert.equal(h.requests.length, before);
+ h.maps[1].setView([30, 60], 8);
+ const oldHash = h.location.hash, oldSaved = h.storage['rx-coverage-view'];
+ oldMove(); assert.equal(h.location.hash, oldHash); assert.equal(h.storage['rx-coverage-view'], oldSaved); assert.equal(h.requests.length, before);
+ let ready;
+ h = harness({ ready: new Promise(r => { ready = r; }) });
+ h.page.init({ innerHTML: '' }); h.page.destroy(); h.page.init({ innerHTML: '' }); ready(); await flush(); assert.equal(h.maps.length, 1, 'old initialization must not survive destroy/re-entry');
+ console.log('RX coverage viewport regressions OK');
+})().catch(error => { console.error(error); process.exit(1); });