diff --git a/.eslintrc.json b/.eslintrc.json index a10d6429..a0c09c18 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -253,6 +253,7 @@ "pad2": "readonly", "pad3": "readonly", "pages": "readonly", + "parseViewportHash": "readonly", "payloadTypeColor": "readonly", "payloadTypeName": "readonly", "process": "readonly", diff --git a/public/app.js b/public/app.js index 9e55a7ae..ae7c4657 100644 --- a/public/app.js +++ b/public/app.js @@ -283,6 +283,54 @@ function getHashParams() { return new URLSearchParams(location.hash.split('?')[1] || ''); } +// parseViewportHash — issue #1709. Parses lat/lon/zoom viewport params from a +// hash query string and returns {lat, lon, zoom} if BOTH lat and lon are valid +// (and zoom, if present, is numeric), otherwise null. Partial lat-only or +// lon-only inputs are intentionally rejected (the issue explicitly forbids +// partial application of a center). When zoom is missing, defaults to 12. When +// zoom is out of the [minZoom, maxZoom] range it is clamped to that range. +// +// `hashOrSearch` may be either a full `location.hash` (e.g. `#/live?lat=...`) +// or a bare query string (e.g. `lat=...&lon=...`). Either is accepted. +// +// Bounds: lat ∈ [-90, 90], lon ∈ [-180, 180]; zoom defaults clamp to [1, 20] +// when bounds not supplied (sensible Leaflet fallback when tile-provider +// minZoom/maxZoom is unknown). +function parseViewportHash(hashOrSearch, opts) { + if (hashOrSearch == null) return null; + var s = String(hashOrSearch); + if (s === '') return null; + // Strip leading '#...?' if present so callers can pass raw location.hash. + var qIdx = s.indexOf('?'); + if (qIdx >= 0) s = s.slice(qIdx + 1); + // Also tolerate a leading '?' on a bare search string. + if (s.charAt(0) === '?') s = s.slice(1); + var params; + try { params = new URLSearchParams(s); } catch (_) { return null; } + var latStr = params.get('lat'); + var lonStr = params.get('lon'); + if (latStr == null || lonStr == null || latStr === '' || lonStr === '') return null; + var lat = parseFloat(latStr); + var lon = parseFloat(lonStr); + if (!isFinite(lat) || !isFinite(lon)) return null; + if (lat < -90 || lat > 90) return null; + if (lon < -180 || lon > 180) return null; + var minZ = (opts && typeof opts.minZoom === 'number') ? opts.minZoom : 1; + var maxZ = (opts && typeof opts.maxZoom === 'number') ? opts.maxZoom : 20; + var zoomStr = params.get('zoom'); + var zoom; + if (zoomStr == null || zoomStr === '') { + zoom = (opts && typeof opts.defaultZoom === 'number') ? opts.defaultZoom : 12; + } else { + zoom = parseFloat(zoomStr); + if (!isFinite(zoom)) return null; + } + if (zoom < minZ) zoom = minZ; + if (zoom > maxZ) zoom = maxZ; + return { lat: lat, lon: lon, zoom: zoom }; +} +if (typeof window !== 'undefined') { window.parseViewportHash = parseViewportHash; } + // shouldEmbedRoute — issue #1369. Returns true when the SPA should render in // "embed" mode (chrome suppressed: no top-nav, no bottom-nav, no side drawer, // content full-bleed). Triggered by ?embed=1 in the hash query string. diff --git a/public/live.js b/public/live.js index e7f962dd..e8e45f67 100644 --- a/public/live.js +++ b/public/live.js @@ -1226,6 +1226,16 @@ if (typeof mapCfg.zoom === 'number') mapZoom = mapCfg.zoom; } catch { } + // #1709: URL hash lat/lon/zoom is the highest-precedence viewport source. + // Applied here so the very first setView() lands at the requested viewport + // (avoids a visible recenter from default → URL after init). + const initVp = (typeof parseViewportHash === 'function') + ? parseViewportHash(location.hash) : null; + if (initVp) { + mapCenter = [initVp.lat, initVp.lon]; + mapZoom = initVp.zoom; + } + map = L.map('liveMap', { zoomControl: false, attributionControl: false, @@ -2193,10 +2203,17 @@ localStorage.setItem('live-feed-width', parseInt(feedEl.style.width)); }); - // Save/restore map view - const savedView = localStorage.getItem('live-map-view'); - if (savedView) { - try { const v = JSON.parse(savedView); map.setView([v.lat, v.lng], v.zoom); } catch {} + // Save/restore map view. #1709: URL hash lat/lon/zoom overrides + // localStorage. Validated via shared parseViewportHash helper. + const urlVp = (typeof parseViewportHash === 'function') + ? parseViewportHash(location.hash) : null; + if (urlVp) { + map.setView([urlVp.lat, urlVp.lon], urlVp.zoom); + } else { + const savedView = localStorage.getItem('live-map-view'); + if (savedView) { + try { const v = JSON.parse(savedView); map.setView([v.lat, v.lng], v.zoom); } catch {} + } } map.on('moveend', () => { const c = map.getCenter(); diff --git a/public/map.js b/public/map.js index 10336be8..386a98c2 100644 --- a/public/map.js +++ b/public/map.js @@ -250,11 +250,14 @@ } catch {} let initCenter = defaultCenter; let initZoom = defaultZoom; - // Check URL query params first (from packet detail links) + // Check URL query params first (from packet detail links). #1709: shared + // parseViewportHash helper validates lat/lon together + clamps zoom. const urlParams = new URLSearchParams(location.hash.split('?')[1] || ''); - if (urlParams.get('lat') && urlParams.get('lon')) { - initCenter = [parseFloat(urlParams.get('lat')), parseFloat(urlParams.get('lon'))]; - initZoom = parseInt(urlParams.get('zoom')) || 12; + const vp = (typeof parseViewportHash === 'function') + ? parseViewportHash(location.hash) : null; + if (vp) { + initCenter = [vp.lat, vp.lon]; + initZoom = vp.zoom; } else { const savedView = localStorage.getItem('map-view'); if (savedView) { diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index b2c373b6..7861f81b 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -6545,6 +6545,111 @@ console.log('\n=== roles.js: Map Tile Config Parsing ==='); }); } +// ===== app.js: parseViewportHash (#1709) ===== +console.log('\n=== app.js: parseViewportHash (#1709) ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const parseViewportHash = ctx.parseViewportHash; + + test('parseViewportHash: valid lat/lon/zoom', () => { + const r = parseViewportHash('#/live?lat=43.0731&lon=-89.4012&zoom=12'); + assert.ok(r && r.lat === 43.0731 && r.lon === -89.4012 && r.zoom === 12); + }); + + test('parseViewportHash: valid lat/lon with missing zoom → defaults to 12', () => { + const r = parseViewportHash('#/live?lat=43.0731&lon=-89.4012'); + assert.ok(r && r.lat === 43.0731 && r.lon === -89.4012 && r.zoom === 12); + }); + + test('parseViewportHash: invalid latitude (out of range) → null', () => { + assert.strictEqual(parseViewportHash('#/live?lat=95&lon=0&zoom=10'), null); + }); + + test('parseViewportHash: invalid longitude (out of range) → null', () => { + assert.strictEqual(parseViewportHash('#/live?lat=0&lon=181&zoom=10'), null); + }); + + test('parseViewportHash: invalid zoom (non-numeric) → null', () => { + assert.strictEqual(parseViewportHash('#/live?lat=0&lon=0&zoom=abc'), null); + }); + + test('parseViewportHash: missing lat → null (no partial center)', () => { + assert.strictEqual(parseViewportHash('#/live?lon=10&zoom=8'), null); + }); + + test('parseViewportHash: missing lon → null (no partial center)', () => { + assert.strictEqual(parseViewportHash('#/live?lat=10&zoom=8'), null); + }); + + test('parseViewportHash: node + viewport combo → viewport parsed', () => { + const r = parseViewportHash('#/live?node=ABC123&lat=43.0731&lon=-89.4012&zoom=12'); + assert.ok(r && r.lat === 43.0731 && r.lon === -89.4012 && r.zoom === 12); + }); + + test('parseViewportHash: zoom clamped to maxZoom from opts', () => { + const r = parseViewportHash('#/live?lat=0&lon=0&zoom=99', { maxZoom: 19 }); + assert.ok(r && r.zoom === 19); + }); + + test('parseViewportHash: zoom clamped to minZoom from opts', () => { + const r = parseViewportHash('#/live?lat=0&lon=0&zoom=0', { minZoom: 1 }); + assert.ok(r && r.zoom === 1); + }); + + test('parseViewportHash: empty/null input → null', () => { + assert.strictEqual(parseViewportHash(''), null); + assert.strictEqual(parseViewportHash(null), null); + assert.strictEqual(parseViewportHash(undefined), null); + }); + + test('parseViewportHash: accepts bare query string (no hash prefix)', () => { + const r = parseViewportHash('lat=1.5&lon=2.5&zoom=8'); + assert.ok(r && r.lat === 1.5 && r.lon === 2.5 && r.zoom === 8); + }); + + test('parseViewportHash: lat at boundary 90 is accepted', () => { + const r = parseViewportHash('lat=90&lon=180&zoom=5'); + assert.ok(r && r.lat === 90 && r.lon === 180); + }); + + test('parseViewportHash: lat at boundary -90 is accepted', () => { + const r = parseViewportHash('lat=-90&lon=-180&zoom=5'); + assert.ok(r && r.lat === -90 && r.lon === -180); + }); +} + +// #1709: live.js node-filter URL update must preserve unrelated viewport params +console.log('\n=== live.js: node-filter URL update preserves lat/lon/zoom (#1709) ==='); +{ + const fs = require('fs'); + const liveSrc = fs.readFileSync('public/live.js', 'utf8'); + + test('node-filter URL update does not unconditionally drop lat/lon/zoom', () => { + // The two history.replaceState call sites in the node-filter flow must + // either (a) build params from getHashParams() so unrelated keys survive, + // or (b) explicitly re-add lat/lon/zoom. We assert (a): both sites must + // seed `params` from getHashParams() rather than a fresh URLSearchParams. + // Grep for the two blocks; both must contain getHashParams within ~6 lines + // before a history.replaceState call that mutates the `node` key. + const idx = liveSrc.indexOf("params.set('node'"); + assert.ok(idx >= 0, "expected params.set('node', val) in live.js node-filter logic"); + // Scan 200 chars before to find a getHashParams() seeding + const before = liveSrc.slice(Math.max(0, idx - 400), idx); + assert.ok(/getHashParams\s*\(/.test(before), + 'node-filter URL update must seed params from getHashParams() to preserve lat/lon/zoom'); + }); + + test('node-filter URL update has no fresh URLSearchParams() with only node key (would clobber viewport)', () => { + // Anti-pattern: `const params = new URLSearchParams(); params.set('node', ...)` + // would silently drop lat/lon/zoom from the URL. Forbid that exact shape. + const bad = /const\s+params\s*=\s*new\s+URLSearchParams\s*\(\s*\)\s*;\s*[^\n]*params\.set\(['"]node['"]/; + assert.ok(!bad.test(liveSrc), + 'node-filter URL update must not start with empty URLSearchParams (would clobber viewport)'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);