fix(#1709): restore Live map viewport from lat/lon/zoom hash params (#1721)

## Summary

Fixes #1709 — implements deep-link viewport restoration on the Live page
so `#/live?lat=43.0731&lon=-89.4012&zoom=12` (and the `node=` combo)
center+zoom the map identically to how `/#/map?lat=...&lon=...&zoom=...`
already worked.

## Approach

Extracted a shared `parseViewportHash(hashOrSearch, opts)` helper in
`public/app.js` (next to existing `getHashParams()`) and wired it into
BOTH call sites — Live and Map — so the parse/validate logic is DRY and
unit-testable.

### `parseViewportHash` contract

- Accepts a full hash (`#/live?lat=...`) OR a bare query string
(`lat=...&lon=...`).
- Returns `{lat, lon, zoom}` only if BOTH `lat` and `lon` parse to
finite numbers within bounds (`lat ∈ [-90, 90]`, `lon ∈ [-180, 180]`).
Partial lat-only or lon-only is rejected — the issue explicitly forbids
partial application of a center.
- `zoom` defaults to 12 when missing, must be numeric when present, and
is clamped to `[minZoom, maxZoom]` (defaults `[1, 20]` — sensible
Leaflet fallback when the tile-provider config isn't supplied).
- Returns `null` for any null/empty/invalid input.

### Precedence chain (Live)

1. **URL hash `lat`/`lon`/`zoom`** — highest priority. Applied BEFORE
the initial `setView()` so the very first render lands at the requested
viewport (no visible recenter from default → URL), AND in the
localStorage-restore block so URL overrides `live-map-view`.
2. `live-map-view` localStorage (existing fallback, preserved).
3. `/api/config/map` defaults (existing default, preserved).

### Node-filter URL preservation

The existing node-filter URL update logic at `public/live.js:1634` /
`1650` already seeds `params` from `getHashParams()`, so unrelated keys
(including `lat`/`lon`/`zoom`) already survive node filter changes.
Added two source-grep regression tests to guard against future
regressions (catches the anti-pattern `const params = new
URLSearchParams(); params.set('node', ...)` which would silently clobber
the viewport).

## Files changed

- `public/app.js` — `+47/-0` — new `parseViewportHash()` helper + window
expose.
- `public/map.js` — `+8/-4` — replaces inline `parseFloat`/`parseInt`
block with helper call.
- `public/live.js` — `+22/-3` — applies helper at init (`setView` line)
AND in the localStorage-restore block so URL overrides both fallbacks.
- `test-frontend-helpers.js` — `+105/-0` — 14 `parseViewportHash` unit
tests + 2 live.js source-grep regression tests for the node-filter URL
flow.

## TDD red→green

- **Red commit** `e6baf935` (FIRST commit on branch): adds tests + a
stub `parseViewportHash` returning `null`. 10 of the 14 unit tests fail
on assertion (not import error); 2 live.js source-grep tests already
pass against current master (regression guards).
- **Green commit** `43b3cb5f`: implements the helper + wires both call
sites. All 16 new tests pass.

## Test output (`node test-frontend-helpers.js`, last 15 lines)

```
   #825: deep link to unencrypted #channel falls through to REST and renders messages
   deriveKey: SHA256("#test")[:16] matches known value
   deriveKey: returns 16 bytes
   #815 preserved: deep link to #channel with stored key triggers decrypt path (no lock)
   invalidateApiCache causes api to re-fetch after cache bust
   computeChannelHash: SHA256(key)[0]
   verifyMAC: valid MAC passes
   verifyMAC: invalid MAC fails
   invalidateApiCache with no prefix busts all entries
   invalidateApiCache with prefix only busts matching

════════════════════════════════════════
  Frontend helpers: 625 passed, 2 failed
════════════════════════════════════════
```

The 2 failures (`favStar returns filled star for favorite`, `favStar
returns empty star for non-favorite`) are **pre-existing on master** and
unrelated to this PR — confirmed by running on `origin/master` before
any changes.

## Acceptance criteria (issue #1709)

1.  `#/live?lat=43.0731&lon=-89.4012&zoom=12` centers Live map at that
lat/lon/zoom.
2.  `#/live?node=ABC123&lat=43.0731&lon=-89.4012&zoom=12` applies BOTH
node filter AND viewport (`getHashParams().get('node')` already feeds
`setNodeFilter`; helper independently parses lat/lon/zoom).
3.  URL viewport params override `live-map-view` localStorage (URL
check runs first AND overrides the savedView branch).
4.  Invalid viewport params ignored safely (`parseViewportHash` returns
`null` on any out-of-range / NaN input).
5.  Missing `lat` or `lon` does NOT partially apply a center (helper
requires both).
6.  Live node-filter URL update preserves unrelated params — existing
`getHashParams()` seeding + new regression tests.
7.  No backend endpoint changes (`grep -l '\.go$' diff` → empty).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ **clean** (all 12 gates pass, no warnings).

---------

Co-authored-by: Kpa-clawbot <bot@kpabap.dev>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
This commit is contained in:
Kpa-clawbot
2026-06-13 10:54:28 -07:00
committed by GitHub
co-authored by Kpa-clawbot Kpa-clawbot
parent 69fba8032d
commit 4d2033da0f
5 changed files with 182 additions and 8 deletions
+1
View File
@@ -253,6 +253,7 @@
"pad2": "readonly",
"pad3": "readonly",
"pages": "readonly",
"parseViewportHash": "readonly",
"payloadTypeColor": "readonly",
"payloadTypeName": "readonly",
"process": "readonly",
+48
View File
@@ -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.
+21 -4
View File
@@ -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();
+7 -4
View File
@@ -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) {
+105
View File
@@ -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)}`);