From be7187cea7ed771e7461eb2bc41a3310ecb9b4ff Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:02:19 +0700 Subject: [PATCH 001/131] fix(server): bound the terrain-heights cache and its request rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every distinct 5-decimal point was a permanent in-memory entry, a permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a live upstream call. Nothing limited the rate, and coordinates were checked for being finite rather than for being on Earth, so an out-of-range pair was still a valid cache key. Four bounds, each following a pattern this repo already uses: - WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no later stage has to. Poles and the antimeridian still parse. - The point cache holds at most 50,000 entries and evicts oldest-first, the shape `trackBackfillProxies` uses. Sized from measurement: this checkout's cache file held 6,189 entries in 1.0 MB of real use, so the ceiling is eight times that working set. Terrain does not move, so an evicted point costs one refetch and never a wrong answer. - A cache file above 24 MB is not read back, mirroring `rocketLaunchesProxy`. An unmeasurable file is not treated as an oversized one: without stat, readFile decides, exactly as before. - The route carries an always-on limiter of 90/min per client, 270 global — the `/api/overpass` band, and an order of magnitude above measured demand (a full test:track run drove 10 requests, peak 8/min). The ceilings are constructor options so they can be exercised as behaviour rather than asserted as constants; production passes none. They are destructured in the body because `proxyErrorResponses.test.mjs` slices these proxies to the first column-zero closing brace. --- CHANGELOG.md | 11 ++ docs/CURRENT-STATE.md | 11 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 4 +- server/providers/terrain.js | 93 +++++++++++- src/data/terrainHeightsProxy.js | 8 ++ src/proxyErrorResponses.test.mjs | 4 + src/terrainHeightsBounds.test.mjs | 232 ++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 src/terrainHeightsBounds.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f59c0c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate and only a finite-number check on the coordinates. + Coordinates outside WGS-84 range are now rejected with `400`, the point cache + holds at most 50,000 entries and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..adf2e7995 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2064,6 +2064,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > tunings. Ambient overlap includes each sprite's own rendered extent. The > gated AIS, CCTV, and satellite passes retain an active-emphasis count so a > settled dim contact always completes its release after tracking ends. +> - **Terrain-height bounds:** because every distinct 5-decimal point is its own +> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own +> upstream call, four limits bound what a caller can grow. Coordinates outside +> WGS-84 range are rejected with `400` at parse (finite is not the same as on +> Earth); the point cache holds at most 50,000 entries, evicting oldest-first +> — terrain does not move, so an evicted point costs one refetch and never a +> wrong answer; a cache file above 24 MB is not read back into memory (the +> ceiling `rocketLaunchesProxy` already applies); and the route carries an +> always-on limiter of 90 requests/minute/client, 270 global. That cap is an +> order of magnitude above measured demand — a full `npm run test:track` run +> drove 10 requests, peaking at 8/min. > - **Terrain-height resilience:** `/api/terrain/heights` caches canonical > 5-decimal points individually, reconstructs reordered/overlapping batches > in exact request order, and refreshes only missing or stale points. Upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..ca43cbbb3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/terrainHeightsBounds.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..d12fc42ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -101,7 +101,9 @@ "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", - "src/data/terrainHeightsProxy.js" + "src/data/terrainHeightsProxy.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index e58a63ea5..ca9442db1 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -8,6 +8,7 @@ import { terrainPointKey, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -20,11 +21,27 @@ import { * Only missing/stale points go upstream; the response is rebuilt in exact * request order. Larger requests are chunked sequentially, and one failing * chunk does not discard the chunks that resolved. + * + * Bounds, because every distinct 5dp point is its own key: coordinates must be + * in WGS-84 range (rejected at parse), the point cache has an entry ceiling + * with oldest-first eviction, an over-large cache file is not read back, and + * the route carries an always-on per-client limiter. */ -export function terrainHeightsProxy() { +export function terrainHeightsProxy(options = {}) { + // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` + // extracts these proxies by slicing to the first closing brace in column + // zero, so a multi-line parameter list would cut the function in half. + // Overridable so the ceilings can be exercised as behaviour rather than + // asserted as constants; production constructs this with no arguments. + const { + cacheMaxEntries = 50_000, + cacheMaxDiskBytes = 24 * 1024 * 1024, + cachePath, + } = options; const TTL_MS = 30 * 24 * 3600_000; - const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + const CACHE_PATH = + cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json'); + const CACHE_DIR = path.dirname(CACHE_PATH); // Sized against measured upstream latency, not the documented page cap. // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows @@ -34,9 +51,50 @@ export function terrainHeightsProxy() { // that range. const UPSTREAM_CHUNK = 64; const MAX_POINTS = 2000; + /** + * Entry ceiling for the point cache, and with it the file written from it. + * + * Every distinct 5dp point is a separate key, so without a ceiling the map + * — and the JSON dumped from it verbatim — grow for as long as a caller + * supplies new coordinates. Measured against real use rather than guessed: + * this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in + * 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is + * eight times that working set and ~8 MB on disk, comfortably inside the + * 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file. + */ + const CACHE_MAX_ENTRIES = cacheMaxEntries; + /** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */ + const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes; + /** + * Requests/min/client. Terrain is keyless and free, so this guards the point + * cache and the upstream community service rather than a bill. Sized from + * measurement: across a full `npm run test:track` run — the heaviest + * exercise this app has — the route served 10 requests, peaking at 8/min. + * 90/min is an order of magnitude above that, and the band `/api/overpass` + * and `/api/military-installations` already use. + */ + const RATE_PER_MIN = 90; + const allow = makeRateLimiter({ + windowMs: 60_000, + max: RATE_PER_MIN, + globalMax: RATE_PER_MIN * 3, + }); /** @type {Map} keyed by canonical 5dp lon/lat. */ const mem = new Map(); + + /** + * Drop the oldest entries until the map is back inside its ceiling. + * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. + * Terrain does not move, so evicting an old point costs one refetch, never + * a wrong answer. + */ + function evictOverflow() { + if (mem.size <= CACHE_MAX_ENTRIES) return; + const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) + mem.delete(byAge[i][0]); + } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); let diskLoaded = false; @@ -47,6 +105,21 @@ export function terrainHeightsProxy() { if (diskLoaded) return; diskLoaded = true; try { + // A cache file past the ceiling is not read back into memory: loading it + // would reinstate exactly the unbounded map the ceiling exists to stop. + // The next flush rewrites a bounded one. + // + // "Cannot measure it" is not "too large": if stat is unavailable, fall + // through and let readFile decide, so an absent file behaves exactly as + // it did before this guard existed. + let sizeBytes = null; + try { + sizeBytes = (await fsp.stat(CACHE_PATH)).size; + } catch { + sizeBytes = null; + } + if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES) + throw new Error('cache file too large'); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const pointEntries = parsed?.version === 2 && @@ -87,6 +160,7 @@ export function terrainHeightsProxy() { } diskDirty = mem.size > 0; } + evictOverflow(); } catch { /* no disk cache yet */ } @@ -163,6 +237,14 @@ export function terrainHeightsProxy() { res.end(JSON.stringify(bodyObj)); }; try { + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); @@ -187,7 +269,10 @@ export function terrainHeightsProxy() { fetchMissing: fetchMissingSingleFlight, ttlMs: TTL_MS, }); - if (outcome.cacheChanged) diskDirty = true; + if (outcome.cacheChanged) { + evictOverflow(); + diskDirty = true; + } if (outcome.upstreamError) { console.warn( '[terrain-heights-proxy] refresh incomplete' + diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..11b8e0037 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -18,6 +18,13 @@ function defaultSleep(ms) { /** * Parse the proxy's `points=lon,lat;...` query parameter. + * + * Finite is not the same as on Earth. A coordinate outside WGS-84 range can + * never resolve to a height, but it is still a distinct 5dp cache key, so + * accepting it meant an out-of-range point became a permanent cache entry and + * a live upstream call. Range is checked here, at the edge, so no later stage + * has to. + * * @param {string|null|undefined} raw * @returns {Array<[number, number]>|null} */ @@ -36,6 +43,7 @@ export function parseTerrainPoints(raw) { const lon = Number(parts[0]); const lat = Number(parts[1]); if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; points.push([lon, lat]); } return points; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..9cc1ff9c7 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; +import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] .map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8')) @@ -22,6 +23,9 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, + // The rate-limited proxies reach for these; give them the real ones so a + // fixture request is throttled exactly as production would be. + makeRateLimiter, clientKey, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs new file mode 100644 index 000000000..1a0c80290 --- /dev/null +++ b/src/terrainHeightsBounds.test.mjs @@ -0,0 +1,232 @@ +// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates. +// +// Every distinct 5dp lon/lat is its own cache key, its own permanent line in +// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things +// bound that: WGS-84 range at parse, an entry ceiling on the map, a size +// ceiling on the file that is read back, and a per-client request limiter. +// +// These cases drive the real exported route handler. `fetch` is stubbed per +// test and restored after, so a case that reaches upstream fails loudly. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; +import { terrainHeightsProxy } from '../server/providers/terrain.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +function stubFetch(t, impl) { + const original = globalThis.fetch; + globalThis.fetch = impl; + t.after(() => { + globalThis.fetch = original; + }); +} + +/** + * Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order. + * Records which point strings were actually asked for, so a test can assert on + * cache misses rather than on internal state. + */ +function stubTerrainUpstream(t) { + const asked = []; + stubFetch(t, async (url) => { + const points = decodeURIComponent( + new URL(String(url)).searchParams.get('points') || '', + ) + .split(';') + .filter(Boolean); + asked.push(...points); + return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) }); + }); + return asked; +} + +test('a coordinate off the globe is not a cache key', () => { + // Finite but not on Earth — the old guard accepted every one of these. + for (const raw of [ + '0,91', + '0,-91', + '181,0', + '-181,0', + '1e9,1e9', + '0,90.00001', + ]) + assert.equal(parseTerrainPoints(raw), null, raw); + + // The poles and the antimeridian are real places and must still parse. + for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26']) + assert.ok(parseTerrainPoints(raw), raw); +}); + +test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => { + let upstreamCalls = 0; + stubFetch(t, async () => { + upstreamCalls += 1; + return new Response('[]', { status: 200 }); + }); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + const refused = await request(route, { url: '/?points=0,91' }); + assert.equal(refused.statusCode, 400); + assert.equal( + upstreamCalls, + 0, + 'an impossible coordinate must not cost an upstream call', + ); +}); + +test('one out-of-range point rejects the batch it is hidden in', async (t) => { + stubFetch(t, async () => new Response('[]', { status: 200 })); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const mixed = await request(route, { + url: '/?points=-97.74,30.26;0,999;-97.75,30.27', + }); + assert.equal(mixed.statusCode, 400); +}); + +test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { + const asked = stubTerrainUpstream(t); + // A ceiling of 3 exercises the same code path 50,000 does, in four requests. + const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( + '/api/terrain/heights', + ); + + const point = (i) => `-97.7${i}000,30.26000`; + for (let i = 1; i <= 4; i += 1) + await request(route, { url: `/?points=${point(i)}` }); + assert.equal(asked.length, 4, 'four distinct points, four upstream lookups'); + + // The newest point is still cached... + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 4, 'a cached point costs nothing'); + + // ...and the oldest was evicted, so it costs one refetch — never a wrong + // answer, because terrain does not move. + await request(route, { url: `/?points=${point(1)}` }); + assert.equal(asked.length, 5, 'the evicted point was asked for again'); + assert.equal(asked[4], point(1)); +}); + +test('an over-large cache file is not read back into memory', async (t) => { + const asked = stubTerrainUpstream(t); + const point = '-97.74000,30.26000'; + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + const cachePath = path.join(dir, 'terrain-heights.json'); + t.after(() => rm(dir, { recursive: true, force: true })); + + // A cache file holding the answer, but larger than the ceiling allows. + const padded = { + version: 2, + points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } }, + _pad: 'x'.repeat(4096), + }; + await writeFile(cachePath, JSON.stringify(padded), 'utf8'); + + const route = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }), + ).get('/api/terrain/heights'); + await request(route, { url: `/?points=${point}` }); + assert.deepEqual( + asked, + [point], + 'the oversized file was skipped, so the point was a miss', + ); + + // The same file under a ceiling that admits it IS used, which is what proves + // the previous assertion came from the size guard and not a broken path. + const admitted = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }), + ).get('/api/terrain/heights'); + await request(admitted, { url: `/?points=${point}` }); + assert.deepEqual(asked, [point], 'a cache within the ceiling is still read'); +}); + +test('the route refuses a flood of requests from one client', async (t) => { + stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + let refusedAt = 0; + for (let i = 1; i <= 95; i += 1) { + // A different point each time: distinct keys are the growth path. + const lon = (-97.7 - i / 1000).toFixed(5); + const answer = await request(route, { url: `/?points=${lon},30.26` }); + if (answer.statusCode === 429) { + refusedAt = i; + break; + } + } + assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); +}); + +test('a second client keeps its own quota', async (t) => { + stubFetch(t, async () => + Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), + ); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + for (let i = 0; i < 90; i += 1) + await request(route, { + url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`, + remoteAddress: '198.51.100.7', + }); + const exhausted = await request(route, { + url: '/?points=-99.5,30.26', + remoteAddress: '198.51.100.7', + }); + assert.equal(exhausted.statusCode, 429); + + const neighbour = await request(route, { + url: '/?points=-99.6,30.26', + remoteAddress: '198.51.100.8', + }); + assert.notEqual(neighbour.statusCode, 429); +}); From a4ce7381d88f7da849d80010fe1d8979457b754c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:32:50 -0500 Subject: [PATCH 002/131] fix(ui): enforce Context ownership before disclosure no-op --- src/ui/panelChrome.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 30266312a..6464f1588 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -474,6 +474,17 @@ export class PanelChrome { syncShare, }); } + // Parent ownership is an invariant even on a no-op disclosure request. + // A stale compact Radio disclosure is absolutely positioned outside the + // measured Context rail; clear it before returning from an already-open + // Context request so it cannot paint over the active Context body. + if ( + !nextCollapsed && + panelId === 'global-context-panel' && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } if ( panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed @@ -502,13 +513,6 @@ export class PanelChrome { this.cockpitView.setContextCollapsed(true); } } - if ( - !nextCollapsed && - panelId === 'global-context-panel' && - this._contextRadioDock?.classList.contains('disclosure-open') - ) { - this._setRadioDisclosure?.(false); - } if ( !nextCollapsed && panelId === 'radio-panel' && From b79ab84a1a5efaffea6eb2540142b22c34a67ca2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:09 -0500 Subject: [PATCH 003/131] test(ui): pin Context and compact Radio ownership --- src/ui/panelChrome.radioOwnership.test.mjs | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/ui/panelChrome.radioOwnership.test.mjs diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs new file mode 100644 index 000000000..c52a4888e --- /dev/null +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PanelChrome } from './panelChrome.js'; + +function classList(initial = []) { + const values = new Set(initial); + return { + contains: (value) => values.has(value), + add: (...items) => items.forEach((item) => values.add(item)), + remove: (...items) => items.forEach((item) => values.delete(item)), + toggle(value, force) { + if (force === true) values.add(value); + else if (force === false) values.delete(value); + else if (values.has(value)) values.delete(value); + else values.add(value); + return values.has(value); + }, + }; +} + +test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => { + const priorDocument = globalThis.document; + const contextPanel = { + id: 'global-context-panel', + classList: classList(), + }; + globalThis.document = { + documentElement: { dataset: { uiTheme: 'tactical' } }, + getElementById(id) { + return id === 'global-context-panel' ? contextPanel : null; + }, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + + const radioDock = { classList: classList(['disclosure-open']) }; + const calls = []; + const owner = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(open); + radioDock.classList.toggle('disclosure-open', open); + }, + _leftPanelStack: null, + _rightPanelStack: null, + _panelLayout: { + _leftStackPreferredPanelId: null, + _rightStackPreferredPanelId: null, + }, + _syncPanelCollapseButton() {}, + _scheduleLeftPanelLayout() {}, + _scheduleRightPanelLayout() {}, + shareLinkManager: null, + cockpitView: null, + }; + + PanelChrome.prototype.setPanelCollapsed.call( + owner, + 'global-context-panel', + false, + ); + + assert.deepEqual(calls, [false]); + assert.equal(radioDock.classList.contains('disclosure-open'), false); +}); From b22e6d519a0387b846a71a3069dc8c66797bf52d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:11 -0500 Subject: [PATCH 004/131] chore(format): adopt panel ownership test --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..507ee3126 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -651,6 +651,7 @@ "src/ui/visualSettings.js", "src/ui/panelChrome.js", "src/testSupport/readShellSource.mjs", + "src/ui/panelChrome.radioOwnership.test.mjs", "src/ui/stateOwners.test.mjs", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", From 4fc813e1752b8e38f6051c6ddcaf0e3e3a97947a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:02 -0500 Subject: [PATCH 005/131] feat(agent): extract model-free World Packet boundary --- src/agent/worldPacket.js | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/agent/worldPacket.js diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..c492f3d6b --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,184 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshots, + viewStateLayerRow, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +const NON_SEMANTIC_PACKET_KEYS = new Set([ + 'note', + 'ageLabel', + 'generatedAt', +]); + +function semanticPacketValue(value) { + if (Array.isArray(value)) return value.map(semanticPacketValue); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const key of Object.keys(value).sort()) { + if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; + out[key] = semanticPacketValue(value[key]); + } + return out; +} + +/** + * Stable semantic fingerprint for change detection, not authentication. + * + * Presentation-only narration/age labels are excluded. Feed state, source, + * lastUpdate, camera, context, tracked entities, and other semantic state stay + * inside the digest. + */ +export function semanticWorldPacketFingerprint(packet) { + const canonical = JSON.stringify(semanticPacketValue(packet)); + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let i = 0; i < canonical.length; i += 1) { + hash ^= BigInt(canonical.charCodeAt(i)); + hash = BigInt.asUintN(64, hash * prime); + } + return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; +} + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), +} = {}) { + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + const rows = dataManager.getAll(); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + layers: rows.map((layer) => viewStateLayerRow(layer, { now })), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} From 5c044a3d8da61eb707c7b61e16e42d6c72fc2ad3 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:05 -0500 Subject: [PATCH 006/131] test(agent): prove World Packet parity and fingerprint semantics --- src/agent/worldPacket.test.mjs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/agent/worldPacket.test.mjs diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..9d9e0bd7d --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { + getWorldPacket, + semanticWorldPacketFingerprint, +} from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(voice, packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + + +test('semantic fingerprint ignores narration prose but changes with observed state', () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); + + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); +}); From 3c163d09a15f2a152cb6ce6de09ff0e82db1a63e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:08 -0500 Subject: [PATCH 007/131] refactor(voice): delegate world state to World Packet builder --- src/voice/gevActions.js | 73 ++++------------------------------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index cfdb1fb0b..cccae249e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -39,6 +39,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { getWorldPacket } from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -2424,25 +2425,6 @@ function runManagedVoiceNavigation( } /** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3073,53 +3055,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - return { - ok: true, - action: 'get_current_view_state', - camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - heightM: cartographic.height, - }, - style: styleManager.activeStyle || 'normal', - context: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - layers: dataManager.getAll().map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( From 6439575d158238ce92470106d221f5d450c6f29c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:11 -0500 Subject: [PATCH 008/131] feat(agent): export World Packet boundary --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e48881c6d..0a299b51d 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", - "./sources/map-features": "./src/sources/overpassFeatures.js" + "./sources/map-features": "./src/sources/overpassFeatures.js", + "./agent/world-packet": "./src/agent/worldPacket.js" } } From c55d4d7c25877abb1a6eec331d82ad0a124bf61e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:14 -0500 Subject: [PATCH 009/131] build: register World Packet package boundary --- scripts/package-boundaries.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..42e98d736 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,15 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "agent-world-packet": { + "exports": ["./agent/world-packet"], + "modules": [ + "src/agent/worldPacket.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/contextModePolicy.js" + ], + "external": ["cesium"] } } From 05b13e9a78b091d1426e5a27889ad274d4cb350b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:48 -0500 Subject: [PATCH 010/131] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 58b7d2d95e7ba3e323a5bd506946f4687df670a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:52:28 +0000 Subject: [PATCH 011/131] style: apply adopted formatting --- src/agent/worldPacket.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index c492f3d6b..e8c522caa 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -24,11 +24,7 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set([ - 'note', - 'ageLabel', - 'generatedAt', -]); +const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); function semanticPacketValue(value) { if (Array.isArray(value)) return value.map(semanticPacketValue); From 6a6703e0c6db8bca4ac9ab7aa27c6df931106f5c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:52:54 -0500 Subject: [PATCH 012/131] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 3c53d63defb180199a039787412e6aa811d43b2d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:55:09 -0500 Subject: [PATCH 013/131] fix: declare World Packet in application boundary --- scripts/package-boundaries.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 42e98d736..b362d28f4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1851,6 +1851,7 @@ "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", + "src/agent/worldPacket.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", From 8acb526d1fc28dbec508b5303f1865bc44c099f2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:57:17 -0500 Subject: [PATCH 014/131] fix: include World Packet feed-state dependencies --- scripts/package-boundaries.json | 746 ++++++++++++++++++++++++-------- 1 file changed, 567 insertions(+), 179 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b362d28f4..260179257 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,27 +12,50 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js", + "src/app/atmosphereCompat.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite", + "./build/html" + ], + "modules": [ + "build/vite.js", + "build/application-html.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -48,16 +71,24 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -75,13 +106,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -93,13 +130,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -107,35 +150,59 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-csv": { - "exports": ["./sources/firms-csv"], - "modules": ["src/data/firmsCsv.js"], + "exports": [ + "./sources/firms-csv" + ], + "modules": [ + "src/data/firmsCsv.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -145,13 +212,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -176,7 +249,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -192,7 +267,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -215,7 +292,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -234,7 +313,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -261,7 +342,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -289,7 +372,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -300,7 +385,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -321,17 +408,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -344,7 +441,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -353,12 +452,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -371,10 +477,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -384,7 +494,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -404,7 +517,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -415,7 +530,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -426,7 +543,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -439,7 +558,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -482,10 +604,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -494,7 +620,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -624,10 +752,14 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +789,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,7 +814,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -729,10 +865,14 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -743,7 +883,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -782,10 +924,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -820,10 +966,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,10 +995,14 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -869,20 +1023,28 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "recent-imagery": { - "exports": ["./layers/recent-imagery"], + "exports": [ + "./layers/recent-imagery" + ], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -891,10 +1053,14 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -914,10 +1080,14 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -939,10 +1109,15 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -966,10 +1141,14 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1001,10 +1180,16 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1028,10 +1213,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1069,10 +1258,16 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1091,10 +1286,14 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1132,10 +1331,15 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": ["hls.js", "cesium"] + "external": [ + "hls.js", + "cesium" + ] }, "alpr-cameras": { - "exports": ["./layers/alpr"], + "exports": [ + "./layers/alpr" + ], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1154,10 +1358,16 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1188,10 +1398,14 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1205,10 +1419,14 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1217,7 +1435,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1253,7 +1474,9 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -2042,12 +2265,18 @@ ] }, "application-catalog": { - "exports": ["./application/catalog"], - "modules": ["src/app/catalog.js"], + "exports": [ + "./application/catalog" + ], + "modules": [ + "src/app/catalog.js" + ], "external": [] }, "application-layer-construction": { - "exports": ["./application/layers"], + "exports": [ + "./application/layers" + ], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2526,7 +2755,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2587,22 +2818,33 @@ ] }, "layer-lifecycle": { - "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "exports": [ + "./data/lifecycle" + ], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js" + ], "external": [] }, "ui-navigation": { - "exports": ["./ui/navigation"], + "exports": [ + "./ui/navigation" + ], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-share-restoration": { - "exports": ["./ui/share-restoration"], + "exports": [ + "./ui/share-restoration" + ], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2615,7 +2857,9 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2640,10 +2884,14 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-panel-chrome": { - "exports": ["./ui/panel-chrome"], + "exports": [ + "./ui/panel-chrome" + ], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2662,7 +2910,9 @@ "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2673,7 +2923,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2681,7 +2933,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2692,7 +2946,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2700,7 +2956,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2708,7 +2966,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2716,37 +2976,68 @@ "external": [] }, "directions-layer": { - "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], - "external": ["cesium"] + "exports": [ + "./layers/directions" + ], + "modules": [ + "src/layers/directions/index.js", + "src/data/routeSteps.js" + ], + "external": [ + "cesium" + ] }, "radio-browser-source": { - "exports": ["./sources/radio-browser"], - "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "exports": [ + "./sources/radio-browser" + ], + "modules": [ + "src/sources/radioBrowser.js", + "src/data/radioCountry.js" + ], "external": [] }, "cctv-types-source": { - "exports": ["./sources/cctv-types"], - "modules": ["src/sources/cctvTypes.js"], + "exports": [ + "./sources/cctv-types" + ], + "modules": [ + "src/sources/cctvTypes.js" + ], "external": [] }, "regional-source": { - "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "exports": [ + "./sources/regional" + ], + "modules": [ + "src/data/regionalModel.js" + ], "external": [] }, "voice-action-schemas": { - "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], + "exports": [ + "./voice/action-schemas" + ], + "modules": [ + "src/voice/actionSchemas.js", + "src/hudLayoutPolicy.js" + ], "external": [] }, "voice-session": { - "exports": ["./voice/session"], - "modules": ["src/voice/session.js"], + "exports": [ + "./voice/session" + ], + "modules": [ + "src/voice/session.js" + ], "external": [] }, "voice-session-controls": { - "exports": ["./voice/session-controls"], + "exports": [ + "./voice/session-controls" + ], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -2755,7 +3046,9 @@ "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2766,20 +3059,34 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "bikeshare-source": { - "exports": ["./layers/bikeshare/source"], - "modules": ["src/layers/bikeshare/source.js"], + "exports": [ + "./layers/bikeshare/source" + ], + "modules": [ + "src/layers/bikeshare/source.js" + ], "external": [] }, "cctv-source": { - "exports": ["./layers/cctv/source"], - "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "exports": [ + "./layers/cctv/source" + ], + "modules": [ + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js" + ], "external": [] }, "earthquakes-source": { - "exports": ["./layers/earthquakes/source"], + "exports": [ + "./layers/earthquakes/source" + ], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -2787,12 +3094,18 @@ "external": [] }, "firms-source": { - "exports": ["./layers/firms/source"], - "modules": ["src/layers/firms/source.js"], + "exports": [ + "./layers/firms/source" + ], + "modules": [ + "src/layers/firms/source.js" + ], "external": [] }, "installations-source": { - "exports": ["./layers/installations/source"], + "exports": [ + "./layers/installations/source" + ], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -2809,25 +3122,43 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "launches-source": { - "exports": ["./layers/launches/source"], - "modules": ["src/layers/launches/source.js"], + "exports": [ + "./layers/launches/source" + ], + "modules": [ + "src/layers/launches/source.js" + ], "external": [] }, "radio-source": { - "exports": ["./layers/radio/source"], - "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "exports": [ + "./layers/radio/source" + ], + "modules": [ + "src/layers/radio/policy.js", + "src/layers/radio/source.js" + ], "external": [] }, "satellites-source": { - "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "exports": [ + "./layers/satellites/source" + ], + "modules": [ + "src/layers/satellites/source.js" + ], "external": [] }, "traffic-source-factory": { - "exports": ["./layers/traffic/source"], + "exports": [ + "./layers/traffic/source" + ], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -2843,10 +3174,16 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "reference-sources": { - "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "exports": [ + "./sources/reference", + "./layers/submarine-cables/source" + ], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -2859,7 +3196,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2872,17 +3211,27 @@ "external": [] }, "source-http-body": { - "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "exports": [ + "./sources/http-body" + ], + "modules": [ + "src/sources/httpBody.js" + ], "external": [] }, "overpass-lexer": { - "exports": ["./sources/overpass"], - "modules": ["src/sources/overpass.js"], + "exports": [ + "./sources/overpass" + ], + "modules": [ + "src/sources/overpass.js" + ], "external": [] }, "nominatim-source": { - "exports": ["./sources/nominatim"], + "exports": [ + "./sources/nominatim" + ], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -2894,7 +3243,9 @@ }, "transit-provider": { "runtime": "node", - "exports": ["./server/providers/transit"], + "exports": [ + "./server/providers/transit" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -2909,20 +3260,28 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-source": { - "exports": ["./sources/transit"], + "exports": [ + "./sources/transit" + ], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-standalone": { - "exports": ["./layers/transit/standalone"], + "exports": [ + "./layers/transit/standalone" + ], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -2981,10 +3340,16 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": ["cesium", "egm96-universal", "pbf"] + "external": [ + "cesium", + "egm96-universal", + "pbf" + ] }, "transit-layer": { - "exports": ["./layers/transit"], + "exports": [ + "./layers/transit" + ], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3018,10 +3383,15 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": ["cesium", "pbf"] + "external": [ + "cesium", + "pbf" + ] }, "transit-source-factory": { - "exports": ["./layers/transit/source"], + "exports": [ + "./layers/transit/source" + ], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3029,7 +3399,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3040,10 +3412,14 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "map-feature-source": { - "exports": ["./sources/map-features"], + "exports": [ + "./sources/map-features" + ], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3054,7 +3430,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3063,10 +3441,14 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "perimeters-source": { - "exports": ["./layers/perimeters/source"], + "exports": [ + "./layers/perimeters/source" + ], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3075,13 +3457,19 @@ "external": [] }, "agent-world-packet": { - "exports": ["./agent/world-packet"], + "exports": [ + "./agent/world-packet" + ], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", "src/data/feedState.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] } } From cbd3b13afb022cf1904dd08e809d58167f6e7340 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:58:20 -0500 Subject: [PATCH 015/131] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 914df05346d50adf13abd3337e0ab1d65daf19b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:59:00 +0000 Subject: [PATCH 016/131] style: apply adopted formatting --- scripts/package-boundaries.json | 742 ++++++++------------------------ 1 file changed, 178 insertions(+), 564 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 260179257..fa4f872a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,50 +12,27 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js", - "src/app/atmosphereCompat.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite", - "./build/html" - ], - "modules": [ - "build/vite.js", - "build/application-html.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite", "./build/html"], + "modules": ["build/vite.js", "build/application-html.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -71,24 +48,16 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -106,19 +75,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -130,19 +93,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -150,59 +107,35 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-csv": { - "exports": [ - "./sources/firms-csv" - ], - "modules": [ - "src/data/firmsCsv.js" - ], + "exports": ["./sources/firms-csv"], + "modules": ["src/data/firmsCsv.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -212,19 +145,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -249,9 +176,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -267,9 +192,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -292,9 +215,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -313,9 +234,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -342,9 +261,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -372,9 +289,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -385,9 +300,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -408,27 +321,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -441,9 +344,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -452,19 +353,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -477,14 +371,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -494,10 +384,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -517,9 +404,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -530,9 +415,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -543,9 +426,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -558,10 +439,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -604,14 +482,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -620,9 +494,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -752,14 +624,10 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -789,9 +657,7 @@ "external": [] }, "director": { - "exports": [ - "./director" - ], + "exports": ["./director"], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -814,9 +680,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -865,14 +729,10 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -883,9 +743,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -924,14 +782,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -966,14 +820,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -995,14 +845,10 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1023,28 +869,20 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "earthquakes": { - "exports": [ - "./layers/earthquakes" - ], + "exports": ["./layers/earthquakes"], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "recent-imagery": { - "exports": [ - "./layers/recent-imagery" - ], + "exports": ["./layers/recent-imagery"], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -1053,14 +891,10 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "fire-layer": { - "exports": [ - "./layers/firms" - ], + "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -1080,14 +914,10 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -1109,15 +939,10 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -1141,14 +966,10 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1180,16 +1001,10 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "awareness-layer": { - "exports": [ - "./layers/awareness" - ], + "exports": ["./layers/awareness"], "modules": [ "src/cyberSonar.js", "src/cockpitMath.js", @@ -1213,14 +1028,10 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "traffic-layer": { - "exports": [ - "./layers/traffic" - ], + "exports": ["./layers/traffic"], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1258,16 +1069,10 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1286,14 +1091,10 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1331,15 +1132,10 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.js" ], - "external": [ - "hls.js", - "cesium" - ] + "external": ["hls.js", "cesium"] }, "alpr-cameras": { - "exports": [ - "./layers/alpr" - ], + "exports": ["./layers/alpr"], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1358,16 +1154,10 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": [ - "cesium", - "@mapbox/vector-tile", - "pbf" - ] + "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "radio-layer": { - "exports": [ - "./layers/radio" - ], + "exports": ["./layers/radio"], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -1398,14 +1188,10 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "submarine-cables": { - "exports": [ - "./layers/submarine-cables" - ], + "exports": ["./layers/submarine-cables"], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1419,14 +1205,10 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "bundled-cables": { - "exports": [ - "./sources/bundled-cables" - ], + "exports": ["./sources/bundled-cables"], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1435,10 +1217,7 @@ "external": [] }, "geography": { - "exports": [ - "./geography/natural-earth", - "./geography/neighborhoods" - ], + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1474,9 +1253,7 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "voice-connection": { "exports": [ @@ -2265,18 +2042,12 @@ ] }, "application-catalog": { - "exports": [ - "./application/catalog" - ], - "modules": [ - "src/app/catalog.js" - ], + "exports": ["./application/catalog"], + "modules": ["src/app/catalog.js"], "external": [] }, "application-layer-construction": { - "exports": [ - "./application/layers" - ], + "exports": ["./application/layers"], "modules": [ "src/cyberSonar.js", "src/data/bhoteKoshiEvent.js", @@ -2755,9 +2526,7 @@ ] }, "application-operations": { - "exports": [ - "./application/operations" - ], + "exports": ["./application/operations"], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2818,33 +2587,22 @@ ] }, "layer-lifecycle": { - "exports": [ - "./data/lifecycle" - ], - "modules": [ - "src/data/lifecycle.js", - "src/data/cameraArrival.js" - ], + "exports": ["./data/lifecycle"], + "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], "external": [] }, "ui-navigation": { - "exports": [ - "./ui/navigation" - ], + "exports": ["./ui/navigation"], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-share-restoration": { - "exports": [ - "./ui/share-restoration" - ], + "exports": ["./ui/share-restoration"], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", @@ -2857,9 +2615,7 @@ "external": [] }, "ui-visual-settings": { - "exports": [ - "./ui/visual-settings" - ], + "exports": ["./ui/visual-settings"], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2884,14 +2640,10 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-panel-chrome": { - "exports": [ - "./ui/panel-chrome" - ], + "exports": ["./ui/panel-chrome"], "modules": [ "src/ui/displayPanelScroll.js", "src/ui/radioPanelPlacement.js", @@ -2910,9 +2662,7 @@ "external": [] }, "civil-flight-records": { - "exports": [ - "./layers/flights/records" - ], + "exports": ["./layers/flights/records"], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2923,9 +2673,7 @@ "external": [] }, "civil-flight-ingestion": { - "exports": [ - "./layers/flights/ingestion" - ], + "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2933,9 +2681,7 @@ "external": [] }, "military-flight-records": { - "exports": [ - "./layers/military/records" - ], + "exports": ["./layers/military/records"], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2946,9 +2692,7 @@ "external": [] }, "military-flight-ingestion": { - "exports": [ - "./layers/military/ingestion" - ], + "exports": ["./layers/military/ingestion"], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2956,9 +2700,7 @@ "external": [] }, "vessel-records": { - "exports": [ - "./layers/vessels/records" - ], + "exports": ["./layers/vessels/records"], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2966,9 +2708,7 @@ "external": [] }, "vessel-ingestion": { - "exports": [ - "./layers/vessels/ingestion" - ], + "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2976,68 +2716,37 @@ "external": [] }, "directions-layer": { - "exports": [ - "./layers/directions" - ], - "modules": [ - "src/layers/directions/index.js", - "src/data/routeSteps.js" - ], - "external": [ - "cesium" - ] + "exports": ["./layers/directions"], + "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], + "external": ["cesium"] }, "radio-browser-source": { - "exports": [ - "./sources/radio-browser" - ], - "modules": [ - "src/sources/radioBrowser.js", - "src/data/radioCountry.js" - ], + "exports": ["./sources/radio-browser"], + "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], "external": [] }, "cctv-types-source": { - "exports": [ - "./sources/cctv-types" - ], - "modules": [ - "src/sources/cctvTypes.js" - ], + "exports": ["./sources/cctv-types"], + "modules": ["src/sources/cctvTypes.js"], "external": [] }, "regional-source": { - "exports": [ - "./sources/regional" - ], - "modules": [ - "src/data/regionalModel.js" - ], + "exports": ["./sources/regional"], + "modules": ["src/data/regionalModel.js"], "external": [] }, "voice-action-schemas": { - "exports": [ - "./voice/action-schemas" - ], - "modules": [ - "src/voice/actionSchemas.js", - "src/hudLayoutPolicy.js" - ], + "exports": ["./voice/action-schemas"], + "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], "external": [] }, "voice-session": { - "exports": [ - "./voice/session" - ], - "modules": [ - "src/voice/session.js" - ], + "exports": ["./voice/session"], + "modules": ["src/voice/session.js"], "external": [] }, "voice-session-controls": { - "exports": [ - "./voice/session-controls" - ], + "exports": ["./voice/session-controls"], "modules": [ "src/voice/sessionCommands.js", "src/voice/session.js", @@ -3046,9 +2755,7 @@ "external": [] }, "alpr-source": { - "exports": [ - "./layers/alpr/source" - ], + "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -3059,34 +2766,20 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "bikeshare-source": { - "exports": [ - "./layers/bikeshare/source" - ], - "modules": [ - "src/layers/bikeshare/source.js" - ], + "exports": ["./layers/bikeshare/source"], + "modules": ["src/layers/bikeshare/source.js"], "external": [] }, "cctv-source": { - "exports": [ - "./layers/cctv/source" - ], - "modules": [ - "src/layers/cctv/source.js", - "src/layers/cctv/sourcePolicy.js" - ], + "exports": ["./layers/cctv/source"], + "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], "external": [] }, "earthquakes-source": { - "exports": [ - "./layers/earthquakes/source" - ], + "exports": ["./layers/earthquakes/source"], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -3094,18 +2787,12 @@ "external": [] }, "firms-source": { - "exports": [ - "./layers/firms/source" - ], - "modules": [ - "src/layers/firms/source.js" - ], + "exports": ["./layers/firms/source"], + "modules": ["src/layers/firms/source.js"], "external": [] }, "installations-source": { - "exports": [ - "./layers/installations/source" - ], + "exports": ["./layers/installations/source"], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -3122,43 +2809,25 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "launches-source": { - "exports": [ - "./layers/launches/source" - ], - "modules": [ - "src/layers/launches/source.js" - ], + "exports": ["./layers/launches/source"], + "modules": ["src/layers/launches/source.js"], "external": [] }, "radio-source": { - "exports": [ - "./layers/radio/source" - ], - "modules": [ - "src/layers/radio/policy.js", - "src/layers/radio/source.js" - ], + "exports": ["./layers/radio/source"], + "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], "external": [] }, "satellites-source": { - "exports": [ - "./layers/satellites/source" - ], - "modules": [ - "src/layers/satellites/source.js" - ], + "exports": ["./layers/satellites/source"], + "modules": ["src/layers/satellites/source.js"], "external": [] }, "traffic-source-factory": { - "exports": [ - "./layers/traffic/source" - ], + "exports": ["./layers/traffic/source"], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -3174,16 +2843,10 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": [ - "@mapbox/vector-tile", - "pbf" - ] + "external": ["@mapbox/vector-tile", "pbf"] }, "reference-sources": { - "exports": [ - "./sources/reference", - "./layers/submarine-cables/source" - ], + "exports": ["./sources/reference", "./layers/submarine-cables/source"], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -3196,9 +2859,7 @@ "external": [] }, "nominatim-provider": { - "exports": [ - "./search/nominatim" - ], + "exports": ["./search/nominatim"], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3211,27 +2872,17 @@ "external": [] }, "source-http-body": { - "exports": [ - "./sources/http-body" - ], - "modules": [ - "src/sources/httpBody.js" - ], + "exports": ["./sources/http-body"], + "modules": ["src/sources/httpBody.js"], "external": [] }, "overpass-lexer": { - "exports": [ - "./sources/overpass" - ], - "modules": [ - "src/sources/overpass.js" - ], + "exports": ["./sources/overpass"], + "modules": ["src/sources/overpass.js"], "external": [] }, "nominatim-source": { - "exports": [ - "./sources/nominatim" - ], + "exports": ["./sources/nominatim"], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -3243,9 +2894,7 @@ }, "transit-provider": { "runtime": "node", - "exports": [ - "./server/providers/transit" - ], + "exports": ["./server/providers/transit"], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -3260,28 +2909,20 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-source": { - "exports": [ - "./sources/transit" - ], + "exports": ["./sources/transit"], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "transit-standalone": { - "exports": [ - "./layers/transit/standalone" - ], + "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -3340,16 +2981,10 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": [ - "cesium", - "egm96-universal", - "pbf" - ] + "external": ["cesium", "egm96-universal", "pbf"] }, "transit-layer": { - "exports": [ - "./layers/transit" - ], + "exports": ["./layers/transit"], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3383,15 +3018,10 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": [ - "cesium", - "pbf" - ] + "external": ["cesium", "pbf"] }, "transit-source-factory": { - "exports": [ - "./layers/transit/source" - ], + "exports": ["./layers/transit/source"], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3399,9 +3029,7 @@ "external": [] }, "transit-service": { - "exports": [ - "./sources/transit-service" - ], + "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3412,14 +3040,10 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": [ - "pbf" - ] + "external": ["pbf"] }, "map-feature-source": { - "exports": [ - "./sources/map-features" - ], + "exports": ["./sources/map-features"], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3430,9 +3054,7 @@ "external": [] }, "perimeters": { - "exports": [ - "./layers/perimeters" - ], + "exports": ["./layers/perimeters"], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3441,14 +3063,10 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "perimeters-source": { - "exports": [ - "./layers/perimeters/source" - ], + "exports": ["./layers/perimeters/source"], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", @@ -3457,9 +3075,7 @@ "external": [] }, "agent-world-packet": { - "exports": [ - "./agent/world-packet" - ], + "exports": ["./agent/world-packet"], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", @@ -3468,8 +3084,6 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } From abea3f2feb99c14a64e5c1884292411b044a0bb2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:59:48 -0500 Subject: [PATCH 017/131] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From a194da21dd6f1fdb4d41170e560e759f5244cf16 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:08 -0500 Subject: [PATCH 018/131] chore: add one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml new file mode 100644 index 000000000..40b244d2a --- /dev/null +++ b/.github/workflows/downstream-format-fix.yml @@ -0,0 +1,37 @@ +name: Downstream format fix + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@v5 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - run: npm run format + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: apply adopted formatting" + git push From 2b7f832f456c933811c8dc0faa75caf111816718 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:50 -0500 Subject: [PATCH 019/131] chore: remove one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 --------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml deleted file mode 100644 index 40b244d2a..000000000 --- a/.github/workflows/downstream-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Downstream format fix - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@v5 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - - run: npm run format - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: apply adopted formatting" - git push From c43f58dc3f41f881f4dcbeae63ca86159448696c Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:46:20 +0200 Subject: [PATCH 020/131] feat: Kartverket ground heights for Statens vegvesen cameras The Vegvesen camera feed is 2D, so every camera shared one flat 150 m ground prior and mountain-pass cameras could sit hundreds of metres below the terrain where neither the point-height prior nor the ground snap resolves. Each camera now gets its terrain height from Kartverket's keyless point-height API (CC BY 4.0): ~840 of ~845 cameras. - 50 points per request, six in flight; points a full batch answers null for are retried once in batches of 10 (the API drops points from large batches). - Sea-floor depths (dybdekurver / Havflate) under bridges and quays count as the water surface, 0 m. - Heights are cached by position, so the 15-minute refresh only asks about new or moved cameras. A catalog load waits at most 12 s; a slower lookup keeps filling the cache for the next refresh. - CCTV_VEGVESEN_HEIGHTS=0 turns it off; Kartverket is credited beside Statens vegvesen in the attribution popover. Follow-up to #929. Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + CHANGELOG.md | 10 ++ DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 2 +- server/providers/cctv/constants.js | 24 +++ server/providers/cctv/sources.js | 225 ++++++++++++++++++++++++++++- src/data/cctvVegvesen.test.mjs | 223 +++++++++++++++++++++++++++- src/data/dataCredits.js | 3 +- 8 files changed, 479 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index ee7620be9..99a3ff88b 100644 --- a/.env.example +++ b/.env.example @@ -195,6 +195,7 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras +# CCTV_VEGVESEN_HEIGHTS=1 # set 0 to skip Kartverket ground heights for Statens vegvesen cameras (flat prior instead) # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..86cda068c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +- Give each Statens vegvesen camera its own ground height from Kartverket's + keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so + mountain-pass cameras no longer sit hundreds of metres below the terrain + where neither the point-height prior nor the ground snap resolves. About + 840 of ~845 cameras get a height; the rest keep the old prior. Bridge and + quay cameras over water use the sea surface, not the sea-floor depth the + API returns there. A catalog load waits at most 12 s for heights, and a + lookup still running then fills a cache the next refresh applies. + `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. Follow-up to #929. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..4959c1c75 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -90,6 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera, 50 points per request with up to six in flight (about 17 requests, ~5 s, for the whole country). Points a full batch answers `null` for are retried once in batches of 10, since the API sometimes drops points from large batches; points still without data keep the flat 150 m prior. Over water the API returns the sea-floor depth (`dybdekurver` / `Havflate`), which is replaced by 0 m, the water surface. Heights are cached in memory by position, so the 15-minute catalog refresh only asks about new or moved cameras. A catalog load waits at most 12 s; a slower lookup keeps filling the cache for the next refresh. Only coordinates are sent. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit is shown beside the Statens vegvesen entry in the Data attribution popover. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..c5bf9df9e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2919,7 +2919,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..7609a3fc5 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,30 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above + * sea level (NN2000) for up to 50 points per request, used as the Vegvesen + * cameras' ground prior because the camera feed's points are 2D. */ +export const KARTVERKET_HEIGHT_URL = + 'https://ws.geonorge.no/hoydedata/v1/punkt'; +export const KARTVERKET_MAX_POINTS = 50; +/** Batch size for the one retry of points a full batch answered null for. */ +export const KARTVERKET_RETRY_POINTS = 10; +/** Batches in flight at once. The whole country is ~17 batches of ~2 s each + * (spread-out points are slow to resolve), so six keep a cold lookup ~6 s. */ +export const KARTVERKET_CONCURRENCY = 6; +/** Deadline for one complete lookup, shared by every batch. */ +export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000; +/** How long a catalog load waits for heights. Kept under the per-provider + * fetch timeout so this pack is never the catalog's slowest lane; a lookup + * still running after this keeps filling the cache, and the next refresh + * applies the rest. At app start, with every pack loading at once, a cold + * lookup takes ~10 s. */ +export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000; +export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024; +/** Plausible Norwegian terrain: below sea level only at the shore, and no road + * camera sits above ~1,500 m. Anything outside is treated as no answer. */ +export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 }); + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..281ddde85 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,14 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + KARTVERKET_HEIGHT_URL, + KARTVERKET_MAX_POINTS, + KARTVERKET_RETRY_POINTS, + KARTVERKET_CONCURRENCY, + KARTVERKET_LOOKUP_TIMEOUT_MS, + KARTVERKET_CATALOG_WAIT_MS, + KARTVERKET_MAX_RESPONSE_BYTES, + KARTVERKET_HEIGHT_RANGE_M, } from './constants.js'; import { toFiniteNumber, @@ -1779,14 +1787,11 @@ export function vegvesenCameraToSource(feature) { fovDeg: 44, rangeM: 145, mountHeightM: 8, - // KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat - // prior for the whole country, while cameras run from sea level to - // mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen). - // The point-height prior and the client's one-shot ground snap correct it - // where they resolve; on a stack where neither does (no 3D tiles), a pass - // camera stays hundreds of metres below the terrain, the same risk the - // Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless - // point-height API) would remove it. + // The feed's coordinates are 2D. The loader replaces this flat prior with + // Kartverket's terrain height for each camera (applyVegvesenGroundHeights); + // it only survives when that lookup fails, and then a mountain-pass camera + // can sit below the terrain on a stack without 3D tiles, the same risk the + // Caltrans pack documents. groundElevationM: 150, feedType: videoUrl ? 'hls' : 'image', url: videoUrl || imageUrl, @@ -1851,6 +1856,7 @@ export async function loadVegvesenSourcesFromOpenData() { ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) : DEFAULT_VEGVESEN_MAX_SOURCES; const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS); + await applyVegvesenGroundHeights(prioritized); console.log( `[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`, ); @@ -1863,3 +1869,206 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * Terrain heights already resolved, keyed by rounded position. Camera + * positions rarely change, so after the first catalog load a 15-minute refresh + * asks Kartverket only for new or moved cameras. Bounded well above the ~900 + * cameras the pack can load. + */ +const kartverketHeightCache = new Map(); +const KARTVERKET_CACHE_LIMIT = 5000; +/** The lookup in flight, so overlapping catalog loads never start a second. */ +let kartverketPending = null; + +/** Cache key for one position, at the ~0.1 m precision the request sends. */ +const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`; + +/** Forget cached heights and any lookup in flight. Tests only. */ +export function clearKartverketHeightCache() { + kartverketHeightCache.clear(); + kartverketPending = null; +} + +/** + * Terrain heights above sea level (NN2000) from Kartverket's keyless + * point-height API, in batches of up to 50 points. Answers are matched back by + * the coordinates the API echoes, not by position in the list. Each answer is + * written to `into` as its batch lands, so a caller that stops waiting still + * keeps what has arrived. One deadline covers every request. + * + * Two quirks of the live API are handled here: + * - A large batch sometimes answers `z: null` for a point it resolves when + * asked again (Valdresflye: null in a 50-point batch, 1,390.7 m alone), so + * every point still missing after the first pass is retried once in batches + * of KARTVERKET_RETRY_POINTS. A point still null then has no data. + * - Over water `/punkt` returns the depth of the sea floor (`dybdekurver`, + * terrain `Havflate`; −547 m under Nordhordlandsbrua). A camera on a bridge + * or quay stands over the water surface, so those answers count as 0 m. + * + * @param {Array<{lon:number, lat:number}>} points + * @param {{into?: Map, timeoutMs?: number}} [options] + * @returns {Promise>} kartverketKey(lon, lat) → metres. + */ +export async function fetchKartverketHeights( + points, + { into = new Map(), timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS } = {}, +) { + const unique = Array.from( + new Map( + (points || []) + .filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon)) + .map((p) => [kartverketKey(p.lon, p.lat), p]), + ).values(), + ); + if (!unique.length) return into; + const signal = AbortSignal.timeout(timeoutMs); + const fetchBatch = async (batch) => { + const url = new URL(KARTVERKET_HEIGHT_URL); + url.searchParams.set( + 'punkter', + JSON.stringify( + batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]), + ), + ); + url.searchParams.set('koordsys', '4258'); + const resp = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal, + }); + if (!resp.ok) { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + throw new Error(`HTTP ${resp.status}`); + } + const payload = await readResponseJsonCapped( + resp, + KARTVERKET_MAX_RESPONSE_BYTES, + ); + for (const point of Array.isArray(payload?.punkter) + ? payload.punkter + : []) { + // `z: null` means no answer for that point; Number(null) would read 0. + if (point?.z === null || point?.z === undefined || point?.z === '') + continue; + const x = toFiniteNumber(point?.x); + const y = toFiniteNumber(point?.y); + let z = toFiniteNumber(point?.z); + if (![x, y, z].every(Number.isFinite)) continue; + if (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate') + z = 0; + if ( + z < KARTVERKET_HEIGHT_RANGE_M.min || + z > KARTVERKET_HEIGHT_RANGE_M.max + ) + continue; + into.set(kartverketKey(x, y), z); + } + }; + let failed = 0; + const runPass = async (list, size) => { + const batches = []; + for (let i = 0; i < list.length; i += size) { + batches.push(list.slice(i, i + size)); + } + let next = 0; + const worker = async () => { + while (next < batches.length && !signal.aborted) { + const batch = batches[next++]; + try { + await fetchBatch(batch); + } catch (error) { + failed += 1; + if (failed === 1) { + console.warn( + '[CCTV] Kartverket height lookup failed:', + error?.message || error, + ); + } + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(KARTVERKET_CONCURRENCY, batches.length) }, + worker, + ), + ); + }; + await runPass(unique, KARTVERKET_MAX_POINTS); + const missing = unique.filter((p) => !into.has(kartverketKey(p.lon, p.lat))); + if (missing.length && !signal.aborted) { + await runPass(missing, KARTVERKET_RETRY_POINTS); + } + return into; +} + +/** + * Give each Vegvesen camera its own terrain height as the ground prior, in + * place. Waits at most `waitMs` for heights it does not have cached; a lookup + * still running then keeps filling the cache for the next catalog refresh. + * Cameras without a height keep the flat prior. Never throws: a failed lookup + * only means the old behavior. CCTV_VEGVESEN_HEIGHTS=0 turns it off. + * + * @param {Array} cameras - Sources from vegvesenCameraToSource(). + * @param {{waitMs?: number}} [options] + * @returns {Promise} How many cameras got a terrain height. + */ +export async function applyVegvesenGroundHeights( + cameras, + { waitMs = KARTVERKET_CATALOG_WAIT_MS } = {}, +) { + if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0; + const list = Array.isArray(cameras) ? cameras : []; + const missing = list.filter( + (c) => !kartverketHeightCache.has(kartverketKey(c.lon, c.lat)), + ); + if (missing.length) { + if (!kartverketPending) { + if ( + kartverketHeightCache.size + missing.length > + KARTVERKET_CACHE_LIMIT + ) { + kartverketHeightCache.clear(); + } + kartverketPending = fetchKartverketHeights(missing, { + into: kartverketHeightCache, + }) + .catch((error) => { + console.warn( + '[CCTV] Kartverket height lookup error:', + error?.message || error, + ); + }) + .finally(() => { + kartverketPending = null; + }); + } + let timer; + await Promise.race([ + kartverketPending, + new Promise((resolve) => { + timer = setTimeout(resolve, waitMs); + timer.unref?.(); + }), + ]); + clearTimeout(timer); + } + let applied = 0; + for (const camera of list) { + const z = kartverketHeightCache.get(kartverketKey(camera.lon, camera.lat)); + if (!Number.isFinite(z)) continue; + camera.groundElevationM = Math.round(z * 10) / 10; + applied += 1; + } + if (list.length) { + console.log( + `[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`, + ); + } + return applied; +} diff --git a/src/data/cctvVegvesen.test.mjs b/src/data/cctvVegvesen.test.mjs index 457fecbda..ffb25aec3 100644 --- a/src/data/cctvVegvesen.test.mjs +++ b/src/data/cctvVegvesen.test.mjs @@ -2,11 +2,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + applyVegvesenGroundHeights, + clearKartverketHeightCache, + fetchKartverketHeights, loadVegvesenSourcesFromOpenData, vegvesenCameraToSource, } from '../../server/providers/cctv/sources.js'; import { DEFAULT_VEGVESEN_CCTV_URL, + KARTVERKET_HEIGHT_URL, + KARTVERKET_MAX_POINTS, + KARTVERKET_RETRY_POINTS, VEGVESEN_IMAGE_ORIGIN, } from '../../server/providers/cctv/constants.js'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; @@ -104,6 +110,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => { }); test('the loader dedupes, and failures degrade to an empty pack', async (t) => { + clearKartverketHeightCache(); + t.after(clearKartverketHeightCache); t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); const requested = []; @@ -127,7 +135,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => { }); }); const cameras = await loadVegvesenSourcesFromOpenData(); - assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]); + // The catalog first; the rest is the Kartverket height lookup. + assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL); + assert.ok( + requested.slice(1).every((url) => url.startsWith(KARTVERKET_HEIGHT_URL)), + ); // Nearest-to-anchor first: Oslo leads. assert.deepEqual( cameras.map((c) => c.id), @@ -200,3 +212,212 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => { ); }); }); + +/** Answer a Kartverket request the way the live API does: echo every point, + * with the height `heightFor(x, y)` returns (null = outside coverage). */ +const kartverketResponse = (url, heightFor) => { + const points = JSON.parse(new URL(url).searchParams.get('punkter')); + return Response.json({ + koordsys: 4258, + punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })), + }); +}; + +/** Cameras at distinct Norwegian positions. */ +const cameraAt = (i) => ({ + id: `no-vegvesen-${i}`, + lat: 60 + i * 0.001, + lon: 7 + i * 0.001, + groundElevationM: 150, +}); + +const withHeightsEnv = (t) => { + const saved = process.env.CCTV_VEGVESEN_HEIGHTS; + delete process.env.CCTV_VEGVESEN_HEIGHTS; + clearKartverketHeightCache(); + t.after(() => { + clearKartverketHeightCache(); + if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS; + else process.env.CCTV_VEGVESEN_HEIGHTS = saved; + }); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +}; + +test('Kartverket heights come in batches of 50, matched by echoed coordinates', async (t) => { + withHeightsEnv(t); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL)); + // Reply in reverse order: matching must not depend on list position. + const response = kartverketResponse(url, (x) => + x > 7.05 ? null : Math.round((x - 7) * 10000), + ); + const body = await response.json(); + body.punkter.reverse(); + return Response.json(body); + }); + const points = Array.from({ length: 120 }, (_, i) => cameraAt(i)); + const heights = await fetchKartverketHeights(points); + const sizes = requested.map( + (url) => JSON.parse(new URL(url).searchParams.get('punkter')).length, + ); + // Three full batches, then one retry pass over the 69 points with no height. + assert.deepEqual(sizes.slice(0, 3), [KARTVERKET_MAX_POINTS, 50, 20]); + assert.equal( + sizes.slice(3).reduce((a, b) => a + b, 0), + 69, + ); + assert.ok(sizes.slice(3).every((n) => n <= KARTVERKET_RETRY_POINTS)); + for (const url of requested) { + assert.equal(new URL(url).searchParams.get('koordsys'), '4258'); + } + // Points past x = 7.05 have no height (outside coverage) and are left out. + assert.equal(heights.size, 51); + assert.equal(heights.get('7.010000,60.010000'), 100); +}); + +test('points a full batch answers null for are retried once in small batches', async (t) => { + withHeightsEnv(t); + const sizes = []; + t.mock.method(globalThis, 'fetch', async (url) => { + const points = JSON.parse(new URL(url).searchParams.get('punkter')); + sizes.push(points.length); + // A full batch drops the first two points; a small batch answers them, + // except the one point that has no data at all. + const full = points.length > KARTVERKET_RETRY_POINTS; + return kartverketResponse(url, (x) => { + if (Math.abs(x - 7.002) < 1e-9) return null; + if (full && x < 7.002) return null; + return 500; + }); + }); + const heights = await fetchKartverketHeights( + Array.from({ length: 60 }, (_, i) => cameraAt(i)), + ); + assert.deepEqual(sizes, [50, 10, 3]); + assert.equal(heights.size, 59); + assert.equal(heights.get('7.000000,60.000000'), 500); + assert.equal(heights.has('7.002000,60.002000'), false); +}); + +test('a sea-floor depth counts as the water surface', async (t) => { + withHeightsEnv(t); + t.mock.method(globalThis, 'fetch', async (url) => { + const points = JSON.parse(new URL(url).searchParams.get('punkter')); + return Response.json({ + koordsys: 4258, + punkter: points.map(([x, y], i) => + i === 0 + ? { x, y, z: -547.1, datakilde: 'dybdekurver', terreng: 'Havflate' } + : { x, y, z: -0.2, datakilde: 'dtm1', terreng: 'ÅpentOmråde' }, + ), + }); + }); + const heights = await fetchKartverketHeights([cameraAt(0), cameraAt(1)]); + // Under a bridge: the surface, not the sea floor. A quay just below the + // datum on land data stays as measured. + assert.equal(heights.get('7.000000,60.000000'), 0); + assert.equal(heights.get('7.001000,60.001000'), -0.2); +}); + +test('implausible heights and points outside Norway are ignored', async (t) => { + withHeightsEnv(t); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return kartverketResponse(url, () => 99999); + }); + const heights = await fetchKartverketHeights([ + cameraAt(1), + { lat: 55.68, lon: 12.57 }, // Copenhagen: never sent + ]); + assert.equal(heights.size, 0); + // The first pass and its one retry, each carrying only the Norway point. + assert.equal(requested.length, 2); + for (const url of requested) { + assert.equal( + JSON.parse(new URL(url).searchParams.get('punkter')).length, + 1, + ); + } +}); + +test('cameras get their terrain height, and a refresh reuses the cache', async (t) => { + withHeightsEnv(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + calls += 1; + return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null)); + }); + const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)]; + assert.equal(await applyVegvesenGroundHeights(cameras), 2); + assert.deepEqual( + cameras.map((c) => c.groundElevationM), + [1043.3, 1043.3, 150], + ); + // One full pass plus one retry for the point with no height. + assert.equal(calls, 2); + // Same positions on the next catalog refresh: answered from the cache. + const again = [cameraAt(0), cameraAt(1)]; + assert.equal(await applyVegvesenGroundHeights(again), 2); + assert.equal(calls, 2); +}); + +test('a failed or disabled lookup keeps the flat prior', async (t) => { + withHeightsEnv(t); + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + throw new Error('offline'); + }); + const cameras = [cameraAt(0)]; + assert.equal(await applyVegvesenGroundHeights(cameras), 0); + assert.equal(cameras[0].groundElevationM, 150); + + fetchMock.mock.mockImplementation( + async () => new Response('nope', { status: 503 }), + ); + assert.equal(await applyVegvesenGroundHeights(cameras), 0); + assert.equal(cameras[0].groundElevationM, 150); + + process.env.CCTV_VEGVESEN_HEIGHTS = '0'; + fetchMock.mock.resetCalls(); + assert.equal(await applyVegvesenGroundHeights(cameras), 0); + assert.equal(fetchMock.mock.callCount(), 0); +}); + +test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => { + withHeightsEnv(t); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(globalThis, 'fetch', async (url) => { + await gate; + return kartverketResponse(url, () => 812); + }); + const first = [cameraAt(0)]; + assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0); + assert.equal(first[0].groundElevationM, 150); + release(); + // The lookup kept running and filled the cache for the next refresh. + await new Promise((resolve) => setTimeout(resolve, 20)); + const next = [cameraAt(0)]; + assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1); + assert.equal(next[0].groundElevationM, 812); +}); + +test('the loader applies Kartverket heights to the cameras it returns', async (t) => { + withHeightsEnv(t); + t.mock.method(globalThis, 'fetch', async (url) => { + if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) { + return kartverketResponse(url, () => 987.6); + } + return Response.json({ type: 'FeatureCollection', features: [feature()] }); + }); + const cameras = await loadVegvesenSourcesFromOpenData(); + assert.deepEqual( + cameras.map((c) => [c.id, c.groundElevationM]), + [['no-vegvesen-3000063_1', 987.6]], + ); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..8ad585eca 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -245,7 +245,8 @@ export const DATA_CREDITS = [ html: 'Road cameras (Norway): contains data under the ' + 'Norwegian licence for Open Government data (NLOD) ' + - 'distributed by Statens vegvesen', + 'distributed by Statens vegvesen · camera ground heights © ' + + 'Kartverket (CC BY 4.0)', }, { key: 'gbfs', From 34d53edc9188c996259b84e8c62ebb4d3697af08 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:21:27 +0200 Subject: [PATCH 021/131] fix: address review on Kartverket ground heights - Send 20 points per request, not 50: above ~20 Kartverket answers the extra points from contour lines (hoydekurver) or not at all. At 20, nearly every camera resolves from dtm1, so the null-retry pass is gone. Eight requests in flight keep a cold lookup ~6 s. - Cache misses: positions with no height are not asked again for 6 h. - Transport failures: the first 429, 5xx or network error stops the lookup with no retries, and no lookup starts until the backoff ends (Retry-After when sent, else 10 min; bounded to 1 min-1 h). - Budget the 12 s wait from the start of the loader, catalog fetch included. - Record answers only for the points each request asked about. - Send the CCTV User-Agent; link CC BY 4.0 in the credit. - Docs: bridge cameras sit at the water surface, below the deck. - Tests: batching, 8-in-flight cap, request count on failure, the shared deadline, Retry-After bounds, null TTL, backoff, the -20 m bound and each water marker on its own. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +- DATA_SOURCES.md | 2 +- server/providers/cctv/constants.js | 39 ++-- server/providers/cctv/sources.js | 267 +++++++++++++++++---------- src/data/cctvVegvesen.test.mjs | 282 ++++++++++++++++++++--------- src/data/dataCredits.js | 3 +- 6 files changed, 407 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86cda068c..152279ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,16 @@ keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so mountain-pass cameras no longer sit hundreds of metres below the terrain where neither the point-height prior nor the ground snap resolves. About - 840 of ~845 cameras get a height; the rest keep the old prior. Bridge and - quay cameras over water use the sea surface, not the sea-floor depth the - API returns there. A catalog load waits at most 12 s for heights, and a - lookup still running then fills a cache the next refresh applies. - `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. Follow-up to #929. + 850 of ~855 cameras get a height; the rest keep the old prior. Requests + carry 20 points, the most the API answers from its 1 m terrain model, eight + at a time (~6 s cold). Cameras over water use the water surface rather than + the sea-floor depth the API returns there; for bridge cameras that is still + below the deck. Heights are cached for good and misses for 6 h, so a + refresh only asks about new or moved cameras. After a 429, a 5xx or a + network failure the lookup stops and backs off (Retry-After, else 10 min). + The lane waits at most 12 s from its start; a slower lookup fills the cache + for the next refresh. `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to + #929. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 4959c1c75..fb53776c0 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -91,7 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. -- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera, 50 points per request with up to six in flight (about 17 requests, ~5 s, for the whole country). Points a full batch answers `null` for are retried once in batches of 10, since the API sometimes drops points from large batches; points still without data keep the flat 150 m prior. Over water the API returns the sea-floor depth (`dybdekurver` / `Havflate`), which is replaced by 0 m, the water surface. Heights are cached in memory by position, so the 15-minute catalog refresh only asks about new or moved cameras. A catalog load waits at most 12 s; a slower lookup keeps filling the cache for the next refresh. Only coordinates are sent. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit is shown beside the Statens vegvesen entry in the Data attribution popover. +- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country (~6 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first 429, 5xx or network failure stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 10 min; bounded to 1 min–1 h). The lane waits at most 12 s, counted from the start of the loader; a slower lookup keeps filling the cache for the next refresh. Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit is shown beside the Statens vegvesen entry in the Data attribution popover. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 7609a3fc5..6e9c6cd04 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -268,28 +268,37 @@ export const NORWAY_ANCHORS = [ ]; /** Kartverket's keyless point-height API (CC BY 4.0): terrain height above - * sea level (NN2000) for up to 50 points per request, used as the Vegvesen - * cameras' ground prior because the camera feed's points are 2D. */ + * sea level (NN2000), used as the Vegvesen cameras' ground prior because the + * camera feed's points are 2D. */ export const KARTVERKET_HEIGHT_URL = 'https://ws.geonorge.no/hoydedata/v1/punkt'; -export const KARTVERKET_MAX_POINTS = 50; -/** Batch size for the one retry of points a full batch answered null for. */ -export const KARTVERKET_RETRY_POINTS = 10; -/** Batches in flight at once. The whole country is ~17 batches of ~2 s each - * (spread-out points are slow to resolve), so six keep a cold lookup ~6 s. */ -export const KARTVERKET_CONCURRENCY = 6; -/** Deadline for one complete lookup, shared by every batch. */ +/** Points per request. The API takes up to 50, but above ~20 it answers the + * extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not + * at all; at 20 nearly every camera resolves from the 1 m terrain model. */ +export const KARTVERKET_MAX_POINTS = 20; +/** Requests in flight at once. The whole country is ~36 requests of ~1-2 s, + * so a cold lookup takes ~6 s. */ +export const KARTVERKET_CONCURRENCY = 8; +/** Deadline for one complete lookup, shared by every request. */ export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000; -/** How long a catalog load waits for heights. Kept under the per-provider - * fetch timeout so this pack is never the catalog's slowest lane; a lookup - * still running after this keeps filling the cache, and the next refresh - * applies the rest. At app start, with every pack loading at once, a cold - * lookup takes ~10 s. */ +/** The Vegvesen lane's budget, measured from the start of the loader (catalog + * fetch included), for waiting on heights. Under the 15 s per-provider fetch + * timeout so this pack is never the catalog's slowest lane. A lookup still + * running after it keeps filling the cache for the next refresh. */ export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000; +/** A point Kartverket has no height for (some ferry quays and bridges) is not + * asked about again for this long. */ +export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000; +/** After a 429, a 5xx or a network failure, no lookup starts for this long, + * or for the Retry-After the service sent, bounded to [1 min, 1 h]. */ +export const KARTVERKET_FAILURE_BACKOFF_MS = 10 * 60 * 1000; +export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000; export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024; /** Plausible Norwegian terrain: below sea level only at the shore, and no road - * camera sits above ~1,500 m. Anything outside is treated as no answer. */ + * camera sits above ~1,500 m. Anything outside is treated as no height. */ export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 }); +/** Same identity the other CCTV upstream requests send. */ +export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0'; /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 281ddde85..15f6df74b 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -67,10 +67,13 @@ import { NORWAY_ANCHORS, KARTVERKET_HEIGHT_URL, KARTVERKET_MAX_POINTS, - KARTVERKET_RETRY_POINTS, KARTVERKET_CONCURRENCY, KARTVERKET_LOOKUP_TIMEOUT_MS, KARTVERKET_CATALOG_WAIT_MS, + KARTVERKET_NULL_TTL_MS, + KARTVERKET_FAILURE_BACKOFF_MS, + KARTVERKET_MAX_BACKOFF_MS, + KARTVERKET_USER_AGENT, KARTVERKET_MAX_RESPONSE_BYTES, KARTVERKET_HEIGHT_RANGE_M, } from './constants.js'; @@ -1811,6 +1814,7 @@ export function vegvesenCameraToSource(feature) { * @returns {Promise>} Normalized camera source objects. */ export async function loadVegvesenSourcesFromOpenData() { + const startedAt = Date.now(); try { const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL; const resp = await fetch(endpoint, { @@ -1856,7 +1860,12 @@ export async function loadVegvesenSourcesFromOpenData() { ? Math.max(8, Math.min(1000, Math.floor(maxRaw))) : DEFAULT_VEGVESEN_MAX_SOURCES; const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS); - await applyVegvesenGroundHeights(prioritized); + await applyVegvesenGroundHeights(prioritized, { + waitMs: Math.max( + 0, + KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt), + ), + }); console.log( `[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`, ); @@ -1871,49 +1880,79 @@ export async function loadVegvesenSourcesFromOpenData() { } /** - * Terrain heights already resolved, keyed by rounded position. Camera - * positions rarely change, so after the first catalog load a 15-minute refresh - * asks Kartverket only for new or moved cameras. Bounded well above the ~900 - * cameras the pack can load. + * Kartverket answers by rounded position: `{z, expires}`, where z is metres or + * null for "no height there". Heights never expire (camera positions barely + * change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog + * refresh therefore only asks about new or moved cameras. Bounded well above + * the ~900 cameras the pack can load. */ const kartverketHeightCache = new Map(); const KARTVERKET_CACHE_LIMIT = 5000; /** The lookup in flight, so overlapping catalog loads never start a second. */ let kartverketPending = null; +/** No lookup starts before this time (epoch ms) after a transport failure. */ +let kartverketBackoffUntil = 0; /** Cache key for one position, at the ~0.1 m precision the request sends. */ const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`; -/** Forget cached heights and any lookup in flight. Tests only. */ +/** Forget cached heights, the backoff and any lookup in flight. Tests only. */ export function clearKartverketHeightCache() { kartverketHeightCache.clear(); kartverketPending = null; + kartverketBackoffUntil = 0; +} + +/** + * How long to back off after a failed request: the Retry-After the service + * sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h]. + * + * @param {?string} retryAfter - The Retry-After header, if any. + * @param {number} now - Epoch ms. + * @returns {number} Milliseconds. + */ +export function kartverketBackoffMs(retryAfter, now = Date.now()) { + let ms = KARTVERKET_FAILURE_BACKOFF_MS; + const text = String(retryAfter ?? '').trim(); + if (/^\d+$/.test(text)) ms = Number(text) * 1000; + else if (text && Number.isFinite(Date.parse(text))) { + ms = Date.parse(text) - now; + } + return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms)); } /** * Terrain heights above sea level (NN2000) from Kartverket's keyless - * point-height API, in batches of up to 50 points. Answers are matched back by - * the coordinates the API echoes, not by position in the list. Each answer is - * written to `into` as its batch lands, so a caller that stops waiting still - * keeps what has arrived. One deadline covers every request. + * point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is + * matched back by the coordinates the API echoes, and only for points that + * request asked about, then reported through `into.set(key, z)` as its + * request lands, so a caller that stops waiting keeps what has arrived. `z` is + * metres, or null when the API has no usable height there. * - * Two quirks of the live API are handled here: - * - A large batch sometimes answers `z: null` for a point it resolves when - * asked again (Valdresflye: null in a 50-point batch, 1,390.7 m alone), so - * every point still missing after the first pass is retried once in batches - * of KARTVERKET_RETRY_POINTS. A point still null then has no data. - * - Over water `/punkt` returns the depth of the sea floor (`dybdekurver`, - * terrain `Havflate`; −547 m under Nordhordlandsbrua). A camera on a bridge - * or quay stands over the water surface, so those answers count as 0 m. + * Over water `/punkt` returns the depth of the sea floor (`datakilde: + * dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either + * marker counts as 0 m, the water surface. For the ~14 bridge cameras that is + * below the deck, but far closer than the sea floor or a flat prior. + * + * The first transport failure (a non-200 answer, a network error or the + * shared deadline) stops the lookup: no further requests start, and the + * failure is returned so the caller can back off. Points in failed requests + * are not reported, so they are asked again after the backoff. * * @param {Array<{lon:number, lat:number}>} points - * @param {{into?: Map, timeoutMs?: number}} [options] - * @returns {Promise>} kartverketKey(lon, lat) → metres. + * @param {{into?: {set: Function}, timeoutMs?: number}} [options] + * @returns {Promise<{heights: Map, failure: ?{status: ?number, retryAfter: ?string}}>} + * `heights` is `into` when it is a Map, else a Map of what was reported. */ export async function fetchKartverketHeights( points, { into = new Map(), timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS } = {}, ) { + const heights = into instanceof Map ? into : new Map(); + const report = (key, z) => { + if (heights !== into) heights.set(key, z); + into.set(key, z); + }; const unique = Array.from( new Map( (points || []) @@ -1921,9 +1960,15 @@ export async function fetchKartverketHeights( .map((p) => [kartverketKey(p.lon, p.lat), p]), ).values(), ); - if (!unique.length) return into; + let failure = null; + if (!unique.length) return { heights, failure }; + const batches = []; + for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) { + batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS)); + } const signal = AbortSignal.timeout(timeoutMs); const fetchBatch = async (batch) => { + const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat))); const url = new URL(KARTVERKET_HEIGHT_URL); url.searchParams.set( 'punkter', @@ -1933,7 +1978,10 @@ export async function fetchKartverketHeights( ); url.searchParams.set('koordsys', '4258'); const resp = await fetch(url, { - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + 'User-Agent': KARTVERKET_USER_AGENT, + }, redirect: 'error', signal, }); @@ -1943,7 +1991,10 @@ export async function fetchKartverketHeights( } catch { /* no-op */ } - throw new Error(`HTTP ${resp.status}`); + const error = new Error(`HTTP ${resp.status}`); + error.status = resp.status; + error.retryAfter = resp.headers.get('retry-after'); + throw error; } const payload = await readResponseJsonCapped( resp, @@ -1952,102 +2003,120 @@ export async function fetchKartverketHeights( for (const point of Array.isArray(payload?.punkter) ? payload.punkter : []) { - // `z: null` means no answer for that point; Number(null) would read 0. - if (point?.z === null || point?.z === undefined || point?.z === '') - continue; const x = toFiniteNumber(point?.x); const y = toFiniteNumber(point?.y); - let z = toFiniteNumber(point?.z); - if (![x, y, z].every(Number.isFinite)) continue; - if (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate') - z = 0; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const key = kartverketKey(x, y); + // Only the points this request asked about: an odd answer can neither + // grow the cache nor overwrite another camera. + if (!asked.has(key)) continue; + asked.delete(key); + // `z: null` means no height there; Number(null) would read 0. + const raw = point?.z; + let z = + raw === null || raw === undefined || raw === '' + ? NaN + : toFiniteNumber(raw); if ( - z < KARTVERKET_HEIGHT_RANGE_M.min || - z > KARTVERKET_HEIGHT_RANGE_M.max - ) - continue; - into.set(kartverketKey(x, y), z); + Number.isFinite(z) && + (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate') + ) { + z = 0; + } + const usable = + Number.isFinite(z) && + z >= KARTVERKET_HEIGHT_RANGE_M.min && + z <= KARTVERKET_HEIGHT_RANGE_M.max; + report(key, usable ? z : null); } }; - let failed = 0; - const runPass = async (list, size) => { - const batches = []; - for (let i = 0; i < list.length; i += size) { - batches.push(list.slice(i, i + size)); - } - let next = 0; - const worker = async () => { - while (next < batches.length && !signal.aborted) { - const batch = batches[next++]; - try { - await fetchBatch(batch); - } catch (error) { - failed += 1; - if (failed === 1) { - console.warn( - '[CCTV] Kartverket height lookup failed:', - error?.message || error, - ); - } + let next = 0; + const worker = async () => { + while (next < batches.length && !failure) { + const batch = batches[next++]; + try { + await fetchBatch(batch); + } catch (error) { + if (!failure) { + failure = { + status: Number.isFinite(error?.status) ? error.status : null, + retryAfter: error?.retryAfter ?? null, + }; + console.warn( + '[CCTV] Kartverket height lookup failed:', + error?.message || error, + ); } } - }; - await Promise.all( - Array.from( - { length: Math.min(KARTVERKET_CONCURRENCY, batches.length) }, - worker, - ), - ); + } }; - await runPass(unique, KARTVERKET_MAX_POINTS); - const missing = unique.filter((p) => !into.has(kartverketKey(p.lon, p.lat))); - if (missing.length && !signal.aborted) { - await runPass(missing, KARTVERKET_RETRY_POINTS); - } - return into; + await Promise.all( + Array.from( + { length: Math.min(KARTVERKET_CONCURRENCY, batches.length) }, + worker, + ), + ); + return { heights, failure }; } /** * Give each Vegvesen camera its own terrain height as the ground prior, in - * place. Waits at most `waitMs` for heights it does not have cached; a lookup + * place. Waits at most `waitMs` for positions it has no answer for; a lookup * still running then keeps filling the cache for the next catalog refresh. - * Cameras without a height keep the flat prior. Never throws: a failed lookup - * only means the old behavior. CCTV_VEGVESEN_HEIGHTS=0 turns it off. + * Cameras without a height keep the flat prior. After a transport failure no + * lookup starts until the backoff ends, so a failing Kartverket costs one + * attempt, not one per refresh. Never throws. CCTV_VEGVESEN_HEIGHTS=0 turns it + * off. * * @param {Array} cameras - Sources from vegvesenCameraToSource(). - * @param {{waitMs?: number}} [options] + * @param {{waitMs?: number, now?: () => number}} [options] * @returns {Promise} How many cameras got a terrain height. */ export async function applyVegvesenGroundHeights( cameras, - { waitMs = KARTVERKET_CATALOG_WAIT_MS } = {}, + { waitMs = KARTVERKET_CATALOG_WAIT_MS, now = Date.now } = {}, ) { if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0; const list = Array.isArray(cameras) ? cameras : []; - const missing = list.filter( - (c) => !kartverketHeightCache.has(kartverketKey(c.lon, c.lat)), - ); - if (missing.length) { - if (!kartverketPending) { - if ( - kartverketHeightCache.size + missing.length > - KARTVERKET_CACHE_LIMIT - ) { - kartverketHeightCache.clear(); - } - kartverketPending = fetchKartverketHeights(missing, { - into: kartverketHeightCache, - }) - .catch((error) => { - console.warn( - '[CCTV] Kartverket height lookup error:', - error?.message || error, - ); - }) - .finally(() => { - kartverketPending = null; - }); + const startedAt = now(); + const missing = list.filter((c) => { + const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat)); + return !entry || entry.expires <= startedAt; + }); + if ( + missing.length && + !kartverketPending && + startedAt >= kartverketBackoffUntil + ) { + if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) { + kartverketHeightCache.clear(); } + const sink = { + set: (key, z) => + kartverketHeightCache.set(key, { + z, + expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity, + }), + }; + kartverketPending = fetchKartverketHeights(missing, { into: sink }) + .then(({ failure }) => { + if (failure) { + kartverketBackoffUntil = + now() + kartverketBackoffMs(failure.retryAfter, now()); + } + }) + .catch((error) => { + kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS; + console.warn( + '[CCTV] Kartverket height lookup error:', + error?.message || error, + ); + }) + .finally(() => { + kartverketPending = null; + }); + } + if (missing.length && kartverketPending && waitMs > 0) { let timer; await Promise.race([ kartverketPending, @@ -2060,7 +2129,9 @@ export async function applyVegvesenGroundHeights( } let applied = 0; for (const camera of list) { - const z = kartverketHeightCache.get(kartverketKey(camera.lon, camera.lat)); + const z = kartverketHeightCache.get( + kartverketKey(camera.lon, camera.lat), + )?.z; if (!Number.isFinite(z)) continue; camera.groundElevationM = Math.round(z * 10) / 10; applied += 1; diff --git a/src/data/cctvVegvesen.test.mjs b/src/data/cctvVegvesen.test.mjs index ffb25aec3..47ae732d1 100644 --- a/src/data/cctvVegvesen.test.mjs +++ b/src/data/cctvVegvesen.test.mjs @@ -5,14 +5,18 @@ import { applyVegvesenGroundHeights, clearKartverketHeightCache, fetchKartverketHeights, + kartverketBackoffMs, loadVegvesenSourcesFromOpenData, vegvesenCameraToSource, } from '../../server/providers/cctv/sources.js'; import { DEFAULT_VEGVESEN_CCTV_URL, KARTVERKET_HEIGHT_URL, + KARTVERKET_CONCURRENCY, + KARTVERKET_FAILURE_BACKOFF_MS, KARTVERKET_MAX_POINTS, - KARTVERKET_RETRY_POINTS, + KARTVERKET_NULL_TTL_MS, + KARTVERKET_USER_AGENT, VEGVESEN_IMAGE_ORIGIN, } from '../../server/providers/cctv/constants.js'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; @@ -244,145 +248,261 @@ const withHeightsEnv = (t) => { t.mock.method(console, 'warn', () => {}); }; -test('Kartverket heights come in batches of 50, matched by echoed coordinates', async (t) => { +const sentPoints = (url) => + JSON.parse(new URL(url).searchParams.get('punkter')); + +test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => { withHeightsEnv(t); const requested = []; - t.mock.method(globalThis, 'fetch', async (url) => { + t.mock.method(globalThis, 'fetch', async (url, init) => { requested.push(String(url)); assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL)); + assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT); // Reply in reverse order: matching must not depend on list position. const response = kartverketResponse(url, (x) => - x > 7.05 ? null : Math.round((x - 7) * 10000), + x > 7.04 ? null : Math.round((x - 7) * 10000), ); const body = await response.json(); body.punkter.reverse(); return Response.json(body); }); - const points = Array.from({ length: 120 }, (_, i) => cameraAt(i)); - const heights = await fetchKartverketHeights(points); - const sizes = requested.map( - (url) => JSON.parse(new URL(url).searchParams.get('punkter')).length, + const { heights, failure } = await fetchKartverketHeights( + Array.from({ length: 50 }, (_, i) => cameraAt(i)), ); - // Three full batches, then one retry pass over the 69 points with no height. - assert.deepEqual(sizes.slice(0, 3), [KARTVERKET_MAX_POINTS, 50, 20]); - assert.equal( - sizes.slice(3).reduce((a, b) => a + b, 0), - 69, + assert.equal(failure, null); + // One pass, no retries: 20 + 20 + 10. + assert.deepEqual( + requested.map((url) => sentPoints(url).length), + [KARTVERKET_MAX_POINTS, 20, 10], ); - assert.ok(sizes.slice(3).every((n) => n <= KARTVERKET_RETRY_POINTS)); for (const url of requested) { assert.equal(new URL(url).searchParams.get('koordsys'), '4258'); } - // Points past x = 7.05 have no height (outside coverage) and are left out. - assert.equal(heights.size, 51); + assert.equal(heights.size, 50); assert.equal(heights.get('7.010000,60.010000'), 100); + // Past x = 7.04 the API has no height: recorded as null, never as 0. + assert.equal(heights.get('7.049000,60.049000'), null); }); -test('points a full batch answers null for are retried once in small batches', async (t) => { - withHeightsEnv(t); - const sizes = []; - t.mock.method(globalThis, 'fetch', async (url) => { - const points = JSON.parse(new URL(url).searchParams.get('punkter')); - sizes.push(points.length); - // A full batch drops the first two points; a small batch answers them, - // except the one point that has no data at all. - const full = points.length > KARTVERKET_RETRY_POINTS; - return kartverketResponse(url, (x) => { - if (Math.abs(x - 7.002) < 1e-9) return null; - if (full && x < 7.002) return null; - return 500; - }); - }); - const heights = await fetchKartverketHeights( - Array.from({ length: 60 }, (_, i) => cameraAt(i)), - ); - assert.deepEqual(sizes, [50, 10, 3]); - assert.equal(heights.size, 59); - assert.equal(heights.get('7.000000,60.000000'), 500); - assert.equal(heights.has('7.002000,60.002000'), false); -}); - -test('a sea-floor depth counts as the water surface', async (t) => { +test('only the points a request asked about are recorded', async (t) => { withHeightsEnv(t); t.mock.method(globalThis, 'fetch', async (url) => { - const points = JSON.parse(new URL(url).searchParams.get('punkter')); + const points = sentPoints(url); return Response.json({ koordsys: 4258, - punkter: points.map(([x, y], i) => - i === 0 - ? { x, y, z: -547.1, datakilde: 'dybdekurver', terreng: 'Havflate' } - : { x, y, z: -0.2, datakilde: 'dtm1', terreng: 'ÅpentOmråde' }, - ), + punkter: [ + ...points.map(([x, y]) => ({ x, y, z: 10 })), + { x: 7.5, y: 60.5, z: 999 }, // never asked + ], }); }); - const heights = await fetchKartverketHeights([cameraAt(0), cameraAt(1)]); - // Under a bridge: the surface, not the sea floor. A quay just below the - // datum on land data stays as measured. - assert.equal(heights.get('7.000000,60.000000'), 0); - assert.equal(heights.get('7.001000,60.001000'), -0.2); + const { heights } = await fetchKartverketHeights([cameraAt(0)]); + assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]); }); -test('implausible heights and points outside Norway are ignored', async (t) => { +test('either water marker counts as the surface', async (t) => { + withHeightsEnv(t); + t.mock.method(globalThis, 'fetch', async (url) => { + const [a, b, c] = sentPoints(url); + return Response.json({ + koordsys: 4258, + punkter: [ + { + x: a[0], + y: a[1], + z: -547.1, + datakilde: 'dybdekurver', + terreng: null, + }, + { x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' }, + { + x: c[0], + y: c[1], + z: -0.2, + datakilde: 'dtm1', + terreng: 'ÅpentOmråde', + }, + ], + }); + }); + const { heights } = await fetchKartverketHeights([ + cameraAt(0), + cameraAt(1), + cameraAt(2), + ]); + assert.equal(heights.get('7.000000,60.000000'), 0); + assert.equal(heights.get('7.001000,60.001000'), 0); + // A quay just below the datum on land data stays as measured. + assert.equal(heights.get('7.002000,60.002000'), -0.2); +}); + +test('heights outside -20..2500 m and points outside Norway are not used', async (t) => { withHeightsEnv(t); const requested = []; t.mock.method(globalThis, 'fetch', async (url) => { requested.push(String(url)); - return kartverketResponse(url, () => 99999); + const zs = [-20, -20.5, 2500, 2500.5]; + const points = sentPoints(url); + return Response.json({ + koordsys: 4258, + punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })), + }); }); - const heights = await fetchKartverketHeights([ + const { heights } = await fetchKartverketHeights([ + cameraAt(0), cameraAt(1), + cameraAt(2), + cameraAt(3), { lat: 55.68, lon: 12.57 }, // Copenhagen: never sent ]); - assert.equal(heights.size, 0); - // The first pass and its one retry, each carrying only the Norway point. - assert.equal(requested.length, 2); - for (const url of requested) { - assert.equal( - JSON.parse(new URL(url).searchParams.get('punkter')).length, - 1, - ); - } + assert.equal(requested.length, 1); + assert.equal(sentPoints(requested[0]).length, 4); + assert.deepEqual( + [0, 1, 2, 3].map((i) => + heights.get( + `${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`, + ), + ), + [-20, null, 2500, null], + ); }); -test('cameras get their terrain height, and a refresh reuses the cache', async (t) => { +test('no more than eight requests are in flight', async (t) => { + withHeightsEnv(t); + let inFlight = 0; + let peak = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return kartverketResponse(url, () => 1); + }); + const { heights } = await fetchKartverketHeights( + Array.from({ length: 400 }, (_, i) => cameraAt(i)), + ); + assert.equal(heights.size, 400); + assert.equal(peak, KARTVERKET_CONCURRENCY); +}); + +test('a transport failure stops the lookup: no retries, no new requests', async (t) => { withHeightsEnv(t); let calls = 0; - t.mock.method(globalThis, 'fetch', async (url) => { + t.mock.method(globalThis, 'fetch', async () => { calls += 1; + return new Response('busy', { + status: 429, + headers: { 'Retry-After': '120' }, + }); + }); + const { heights, failure } = await fetchKartverketHeights( + Array.from({ length: 400 }, (_, i) => cameraAt(i)), + ); + // The first wave is already in flight when the first failure lands; nothing + // after it starts, and no point is resent. + assert.equal(calls, KARTVERKET_CONCURRENCY); + assert.equal(heights.size, 0); + assert.deepEqual(failure, { status: 429, retryAfter: '120' }); +}); + +test('the lookup deadline bounds a hanging service', async (t) => { + withHeightsEnv(t); + t.mock.method( + globalThis, + 'fetch', + (url, init) => + new Promise((_, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason)); + }), + ); + const started = Date.now(); + const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], { + timeoutMs: 30, + }); + assert.ok(Date.now() - started < 2000); + assert.equal(heights.size, 0); + assert.equal(failure.status, null); +}); + +test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => { + const now = Date.parse('2026-10-06T12:00:00Z'); + assert.equal(kartverketBackoffMs('120', now), 120_000); + assert.equal(kartverketBackoffMs('5', now), 60_000); + assert.equal(kartverketBackoffMs('86400', now), 3_600_000); + assert.equal( + kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now), + 300_000, + ); + assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS); + assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS); +}); + +test('heights are cached, and misses are not asked again until they expire', async (t) => { + withHeightsEnv(t); + let clock = 1_000_000; + const now = () => clock; + const asked = []; + t.mock.method(globalThis, 'fetch', async (url) => { + asked.push(sentPoints(url).length); return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null)); }); const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)]; - assert.equal(await applyVegvesenGroundHeights(cameras), 2); + assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2); assert.deepEqual( cameras.map((c) => c.groundElevationM), [1043.3, 1043.3, 150], ); - // One full pass plus one retry for the point with no height. - assert.equal(calls, 2); - // Same positions on the next catalog refresh: answered from the cache. - const again = [cameraAt(0), cameraAt(1)]; - assert.equal(await applyVegvesenGroundHeights(again), 2); - assert.equal(calls, 2); + // The next refresh asks nothing: heights and the miss are both cached. + assert.equal( + await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], { + now, + }), + 2, + ); + assert.deepEqual(asked, [3]); + // Once the miss expires, only that point is asked again. + clock += KARTVERKET_NULL_TTL_MS + 1; + await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now }); + assert.deepEqual(asked, [3, 1]); }); -test('a failed or disabled lookup keeps the flat prior', async (t) => { +test('after a failure no lookup starts until the backoff ends', async (t) => { withHeightsEnv(t); + let clock = 1_000_000; + const now = () => clock; const fetchMock = t.mock.method(globalThis, 'fetch', async () => { throw new Error('offline'); }); const cameras = [cameraAt(0)]; - assert.equal(await applyVegvesenGroundHeights(cameras), 0); + assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0); assert.equal(cameras[0].groundElevationM, 150); + assert.equal(fetchMock.mock.callCount(), 1); - fetchMock.mock.mockImplementation( - async () => new Response('nope', { status: 503 }), + // Refreshes inside the backoff cost nothing. + clock += KARTVERKET_FAILURE_BACKOFF_MS - 1; + assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0); + assert.equal(fetchMock.mock.callCount(), 1); + + // After it, the failed point is asked again. + clock += 2; + fetchMock.mock.mockImplementation(async (url) => + kartverketResponse(url, () => 321), ); + const later = [cameraAt(0)]; + assert.equal(await applyVegvesenGroundHeights(later, { now }), 1); + assert.equal(later[0].groundElevationM, 321); + assert.equal(fetchMock.mock.callCount(), 2); +}); + +test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => { + withHeightsEnv(t); + const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => + kartverketResponse(url, () => 5), + ); + process.env.CCTV_VEGVESEN_HEIGHTS = '0'; + const cameras = [cameraAt(0)]; assert.equal(await applyVegvesenGroundHeights(cameras), 0); assert.equal(cameras[0].groundElevationM, 150); - - process.env.CCTV_VEGVESEN_HEIGHTS = '0'; - fetchMock.mock.resetCalls(); - assert.equal(await applyVegvesenGroundHeights(cameras), 0); assert.equal(fetchMock.mock.callCount(), 0); }); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 8ad585eca..aa8e8f489 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -246,7 +246,8 @@ export const DATA_CREDITS = [ 'Road cameras (Norway): contains data under the ' + 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen · camera ground heights © ' + - 'Kartverket (CC BY 4.0)', + 'Kartverket ' + + '(CC BY 4.0)', }, { key: 'gbfs', From fbbc4cda5c1ec178b75679c12008ee6809eaa94f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 21:39:33 +0400 Subject: [PATCH 022/131] refactor(ui): remove floating, resizable rail panels PR #768 bundled a portable-panel windowing system with the Street Level layer. Remove it so CCTV and Street Level are ordinary docked right-rail panels again, as on main before the merge. Removed: - header drag to float, 8-way edge/corner resize, double-press snap-back, dockOnCollapse, command-dock clearance, float hint toast, floating z floor, persisted floating geometry and ResizeObserver re-clamping (panelPositionControls.js restored to its pre-#768 version) - src/ui/panelResize.js and its tests, panelDock.test.mjs - onPanelResized/dockPanel wiring in PanelChrome, the application shell and StreetLevelControls (EXPAND/SHRINK no longer docks) - :not(.panel-floating) guards and .panel-floating/.panel-portable/resize CSS - qa:panel-resize, qa-panel-resize.mjs, qa-panelDrag.mjs, their tests, the CI "Portable panel gate" step, QA_FAIL_ON_RETRY, and the Street Level gate's float/resize/dock steps - floating-panel docs in README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES Kept: the Street Level panel as a docked rail panel (share/cockpit/rail lists, layout controller, rail and Cyber CSS, EXPAND viewer), and the independent right-rail alignment fix (settledLeftRailTop, 9dde7b4). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 - CHANGELOG.md | 4 - README.md | 2 +- docs/CURRENT-STATE.md | 15 +- docs/KNOWN-ISSUES.md | 6 +- package.json | 1 - scripts/format-scope.json | 5 - scripts/package-boundaries.json | 3 - scripts/qa-panel-resize.mjs | 456 ------------------ scripts/qa-panelDrag.mjs | 319 ------------- scripts/qa-street-level.mjs | 285 +++-------- src/rightRailPolicy.test.mjs | 6 +- src/streetLevelMarkup.test.mjs | 49 +- src/tooling/panelResizeQa.test.mjs | 57 --- src/tooling/streetLevelQa.test.mjs | 267 +---------- src/ui/applicationShell.js | 9 - src/ui/cyberPanelDisclosure.test.mjs | 13 - src/ui/panelChrome.js | 16 +- src/ui/panelDock.test.mjs | 580 ----------------------- src/ui/panelLayoutController.js | 9 +- src/ui/panelPositionControls.js | 684 +++------------------------ src/ui/panelRails.test.mjs | 27 +- src/ui/panelResize.js | 47 -- src/ui/panelResize.test.mjs | 64 --- src/ui/rightPanelRail.js | 4 +- src/ui/streetLevelControls.js | 15 +- src/ui/streetLevelControls.test.mjs | 2 - src/ui/styles/controls.css | 109 ----- src/ui/styles/layers.css | 31 +- src/ui/styles/responsive.css | 8 +- src/ui/styles/street-level.css | 47 -- 31 files changed, 201 insertions(+), 2945 deletions(-) delete mode 100644 scripts/qa-panel-resize.mjs delete mode 100644 scripts/qa-panelDrag.mjs delete mode 100644 src/tooling/panelResizeQa.test.mjs delete mode 100644 src/ui/panelDock.test.mjs delete mode 100644 src/ui/panelResize.js delete mode 100644 src/ui/panelResize.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..933f3291e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,6 @@ jobs: timeout-minutes: 30 env: GEV_REQUIRE_ALLOCATION_GATE: '0' - # Fail on a missed header press unless the panel itself moved. - QA_FAIL_ON_RETRY: '1' steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -104,10 +102,6 @@ jobs: - name: Street Level gate with Mapillary fixtures run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173 - - name: Portable panel gate - if: success() || failure() - run: npm run qa:panel-resize -- --url http://localhost:4173 - - name: Preview server log if: failure() run: cat preview.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3876f79..887a21566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,6 @@ refuses cross-site requests, rate-limits per IP and backs off when Mapillary rejects the token or rate-limits. -- The CCTV and Street Level panels are portable: drag the header to float the - panel, resize it from any edge or corner, and double-click the header to dock - it again. The position persists across reloads. - - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/README.md b/README.md index 1c2f375b5..22437b67f 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. -Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## 🎖️ Field Missions diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d51be986b..a1f7e1612 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2978,11 +2978,8 @@ and is refused in the cockpit: turning FOLLOW on claims the camera at once, and FOLLOW stops when another feature takes it; a photo takes a deferred ticket when it starts opening and frames only if nothing newer took the camera while it loaded. SINCE is a stepped slider whose readout names the -cut-off date. The panel is portable (`street-level-panel` spec, minimum -320 × 280, `dockOnCollapse`): a floating window gives spare height to the -viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND -docks it. At phone width (≤720 px) the viewer height is derived from the rail -band so the whole photo fits. +cut-off date. At phone width (≤720 px) the viewer height is derived from the +rail band so the whole photo fits. Providers implement the contract in `src/layers/streetLevel/registry.js`. The core routes clicks by pick prefix, swaps viewer adapters in the one host, @@ -2998,8 +2995,8 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request from fixtures (`providers/mapillary/coverageFixture.mjs`, `scripts/fixtures/street-level/`), so it needs no network and covers the whole -photo flow. CI runs the fixture gate and `qa:panel-resize` -against a production build served by `vite preview` with a dummy token. +photo flow. CI runs the fixture gate against a production build served by +`vite preview` with a dummy token. Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the @@ -4429,8 +4426,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index e93dd0ccf..e41623cc6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,9 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. -- Panel positions: `godsEyeView.v8.panelPos.` — written only for a - floating portable panel (CCTV, Street Level). Deleting it, or double-clicking - the panel header, docks the panel again. +- Panel positions: `godsEyeView.v8.panelPos.` — the versioned name for a + stored position. The current layout writes none, so deleting one changes + nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` --- diff --git a/package.json b/package.json index 9222cd6c4..cbfe75a0b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "test": "node scripts/run-unit-tests.mjs", "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", - "qa:panel-resize": "node scripts/qa-panel-resize.mjs", "qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7062704a7..94abd4e08 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -36,9 +36,6 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", - "scripts/qa-panel-resize.mjs", - "scripts/qa-panelDrag.mjs", - "src/tooling/panelResizeQa.test.mjs", "scripts/qa-terrain-429.mjs", "src/app/application.js", "src/app/application.test.mjs", @@ -168,7 +165,6 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", - "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", @@ -917,7 +913,6 @@ "src/layers/streetLevel/providers/mapillary/coverageFixture.mjs", "src/layers/streetLevel/cameraFollow.test.mjs", "src/ui/streetLevelControls.test.mjs", - "src/ui/panelDock.test.mjs", "src/layers/streetLevel/marker.test.mjs", "src/layers/streetLevel/viewerHost.test.mjs", "src/layers/streetLevel/providers/mapillary/sequences.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ce778ee95..4f53ab761 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -623,7 +623,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1912,7 +1911,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2809,7 +2807,6 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", - "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs deleted file mode 100644 index 026b6f2fb..000000000 --- a/scripts/qa-panel-resize.mjs +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env node -/** - * Browser QA for the floating CCTV panel: lift, resize from every handle, - * minimum size, reload, and dock again. `--fail-on-retry` (or - * QA_FAIL_ON_RETRY=1) fails on any header-press retry. - */ -import assert from 'node:assert/strict'; -import { pathToFileURL } from 'node:url'; -import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; -import { - describePress, - dockPanelByDoubleClick, - dragPointer, - liftPanelByHeader, - nextFrames, -} from './qa-panelDrag.mjs'; -import { - hookRenderErrors, - readRenderErrors, - saveFailureArtifacts, - watchPage, -} from './qa-browserEvidence.mjs'; - -export const PANEL_ID = 'cctv-panel'; -export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; -export const MIN_SIZE = { width: 300, height: 160 }; -export const ARTIFACT_DIR = 'qa-artifacts/panel-resize'; -const VIEWPORT = { width: 1400, height: 900 }; -const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; - -/** Box edges a resize in `dir` must leave exactly where they were. */ -export function pinnedEdges(dir) { - return Object.entries(EDGE_BY_SIDE) - .filter(([side]) => !dir.includes(side)) - .map(([, edge]) => edge); -} - -/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ -export function growthDelta(dir, amount) { - return { - dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, - dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, - }; -} - -/** - * Viewport point inside the handle for `dir`, given the panel's client rect. - * Edge strips straddle the border by 3px; corners and the grip sit inside. - */ -export function handlePoint(rect, dir) { - const inset = dir.length === 2 ? 4 : 1; - const x = dir.includes('w') - ? rect.left + inset - : dir.includes('e') - ? rect.right - inset - : rect.left + rect.width / 2; - const y = dir.includes('n') - ? rect.top + inset - : dir.includes('s') - ? rect.bottom - inset - : rect.top + rect.height / 2; - return { x, y }; -} - -/** Pinned edges that moved during a resize in `dir`. */ -export function driftedEdges(before, after, dir, tolerance = 1.5) { - const edges = (box) => ({ - left: box.left, - top: box.top, - right: box.left + box.width, - bottom: box.top + box.height, - }); - const a = edges(before); - const b = edges(after); - return pinnedEdges(dir).filter( - (edge) => Math.abs(a[edge] - b[edge]) > tolerance, - ); -} - -function near(actual, expected, tolerance, label) { - assert.ok( - Math.abs(actual - expected) <= tolerance, - `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, - ); -} - -async function main() { - const { default: puppeteer } = await import('puppeteer'); - const args = process.argv.slice(2); - const urlIndex = args.indexOf('--url'); - const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; - const browser = await puppeteer.launch({ - headless: true, - executablePath: - process.env.PUPPETEER_EXECUTABLE_PATH || - (await puppeteer.executablePath()), - args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], - }); - const monitors = []; - try { - const page = await browser.newPage(); - await page.setViewport(VIEWPORT); - const errors = []; - monitors.push(watchPage(page, { name: 'panel', errors })); - await hookRenderErrors(page); - - const boot = async () => { - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => window.__godsEyeView?.viewer, { - timeout: 90_000, - }); - await page.waitForSelector(`#${PANEL_ID} .panel-header`); - await page.evaluate(() => { - document.querySelector('.first-run-explore')?.click(); - }); - await page.keyboard.press('Escape'); - await page.evaluate(() => { - document.getElementById('first-run-launcher')?.remove(); - for (const node of document.querySelectorAll('[class*=first-run]')) - node.remove(); - }); - await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); - await nextFrames(page); - }; - const readPanel = () => - page.evaluate((id) => { - const panel = document.getElementById(id); - const rect = panel.getBoundingClientRect(); - return { - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - // handlePoint() reads the far edges for s/e handles. - right: rect.right, - bottom: rect.bottom, - floating: panel.classList.contains('panel-floating'), - collapsed: panel.classList.contains('collapsed'), - inRail: panel.parentElement?.id === 'right-context-rail', - allocated: panel.style.getPropertyValue( - '--right-panel-allocated-height', - ), - inlineWidth: panel.style.width, - inlineHeight: panel.style.height, - stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), - }; - }, PANEL_ID); - /** - * Best effort: wait for the box to hold still for half a second. The lift - * is checked against the box at pointerdown, so a timeout only costs time. - */ - const settle = async () => { - try { - await page.waitForFunction( - (id) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height] - .map(Math.round) - .join(','); - const now = performance.now(); - if (window.__qaLastBox !== box) { - window.__qaLastBox = box; - window.__qaSince = now; - } - return now - window.__qaSince >= 500; - }, - { polling: 100, timeout: 20_000 }, - PANEL_ID, - ); - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - console.log('note: the CCTV panel was still moving; lifting anyway'); - } - }; - const headerPoint = async () => { - const rect = await page.evaluate((id) => { - const title = document.querySelector( - `#${id} .panel-header .panel-title`, - ); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, PANEL_ID); - return rect; - }; - const drag = (from, dx, dy) => dragPointer(page, from, dx, dy); - - await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); - await page.evaluate((key) => { - localStorage.removeItem(key); - localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); - }, STORAGE_KEY); - await boot(); - - await page.evaluate((id) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed')) - document - .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) - .click(); - }, PANEL_ID); - await page.waitForFunction( - (id) => !document.getElementById(id).classList.contains('collapsed'), - {}, - PANEL_ID, - ); - // The rail animates panel heights, and on a slow software renderer the - // panels above CCTV can still be moving after a fixed sleep. - await settle(); - const docked = await readPanel(); - assert.equal(docked.floating, false); - assert.equal(docked.inRail, true); - - // Names whatever above CCTV in the rail shifted it, for a retry note. - const railAbove = () => - page.evaluate((id) => { - const out = []; - for (const node of document.getElementById('right-context-rail') - ?.children || []) { - if (node.id === id) break; - out.push( - `${node.id || node.className}:${Math.round(node.offsetHeight)}`, - ); - } - return out.join(' '); - }, PANEL_ID); - const aboveAtDock = await railAbove(); - // On CI a panel above CCTV grows after load and can shift the header out - // from under the press; the helper retries only such a provable miss. - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -200, - dy: -100, - note: async () => - `rail above CCTV ${aboveAtDock} -> ${await railAbove()}`, - }); - const { pressed } = attempt; - const lifted = await readPanel(); - assert.equal( - lifted.floating, - true, - `a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`, - ); - assert.equal( - lifted.collapsed, - false, - 'the drag-ending click must not collapse', - ); - const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`; - near( - lifted.left, - Math.max(6, pressed.left - 200), - 2, - `lifted left (${moved})`, - ); - near( - lifted.top, - Math.max(6, pressed.top - 100), - 2, - `lifted top (${moved})`, - ); - near(lifted.width, pressed.width, 2, 'lifted width'); - // The rail lets go of the lifted panel on its next layout pass. - await page - .waitForFunction( - (id) => - document - .getElementById(id) - .style.getPropertyValue('--right-panel-allocated-height') === '', - { timeout: 5_000 }, - PANEL_ID, - ) - .catch(() => {}); - const afterLayout = await readPanel(); - assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); - const railExcludes = await page.evaluate((id) => { - const rail = document.getElementById('right-context-rail'); - // Same rule as the rail: hidden panels take no room. - const counted = [...rail.children].filter((panel) => - panel.matches( - '[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])', - ), - ); - return { - expandedCount: rail.dataset.expandedCount, - counted: counted.length, - includesPanel: counted.some((panel) => panel.id === id), - }; - }, PANEL_ID); - assert.equal(railExcludes.includesPanel, false); - if (railExcludes.expandedCount !== undefined) - assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); - const storedAfterLift = JSON.parse(afterLayout.stored); - assert.equal(storedAfterLift.floating, true); - assert.equal( - 'height' in storedAfterLift, - false, - 'a drag alone must not freeze the measured height', - ); - console.log('PASS: header drag lifts CCTV out of the rail'); - - // Park near the top: the voice dock would cover the south corners once - // the loop below has grown the window. - { - const header = await headerPoint(); - await drag(header, 0, 120 - header.y); - } - - const limits = { - minWidth: MIN_SIZE.width, - minHeight: MIN_SIZE.height, - viewportWidth: VIEWPORT.width, - viewportHeight: VIEWPORT.height, - }; - for (const dir of RESIZE_DIRECTIONS) { - const before = await readPanel(); - const { dx, dy } = growthDelta(dir, 40); - const point = handlePoint(before, dir); - const hit = await page.evaluate(({ x, y }) => { - const node = document.elementFromPoint(x, y); - if (!node) return 'nothing'; - const dirAttr = node.dataset?.dir - ? `[data-dir=${node.dataset.dir}]` - : ''; - return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`; - }, point); - await drag(point, dx, dy); - const after = await readPanel(); - const expected = resizeBox(before, dir, dx, dy, limits); - for (const key of ['left', 'top', 'width', 'height']) - near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`); - assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); - } - console.log('PASS: eight resize handles keep the opposite edge pinned'); - - let before = await readPanel(); - await drag(handlePoint(before, 'se'), -2000, -2000); - let after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (se)'); - near(after.height, MIN_SIZE.height, 2, 'min height (se)'); - assert.deepEqual(driftedEdges(before, after, 'se', 2), []); - before = after; - await drag(handlePoint(before, 'nw'), 2000, 2000); - after = await readPanel(); - near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); - near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); - assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); - console.log('PASS: minimum size holds from both corners'); - - const saved = await readPanel(); - const record = JSON.parse(saved.stored); - assert.equal(record.floating, true); - await boot(); - const restored = await readPanel(); - assert.equal(restored.floating, true, 'floating survives a reload'); - for (const key of ['left', 'top', 'width', 'height']) - near(restored[key], record[key], 2, `restored ${key}`); - console.log('PASS: reload restores position and size'); - - // A collapsed window keeps the height chosen for it, through a drag of - // its header strip and a reload. - const setCollapsed = async (collapsed) => { - await page.evaluate( - (id, want) => { - const panel = document.getElementById(id); - if (panel.classList.contains('collapsed') !== want) - document - .querySelector( - `.panel-collapse-btn[data-collapse-target="${id}"]`, - ) - .click(); - }, - PANEL_ID, - collapsed, - ); - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('collapsed') === want, - {}, - PANEL_ID, - collapsed, - ); - await nextFrames(page); - }; - await drag(handlePoint(await readPanel(), 'se'), 0, 200); - const chosen = await readPanel(); - assert.ok( - chosen.height > MIN_SIZE.height + 150, - `the window grew to ${chosen.height}px`, - ); - await setCollapsed(true); - const strip = await readPanel(); - assert.ok(strip.height < chosen.height - 100, 'collapsed to its header'); - await drag(await headerPoint(), 40, 30); - const movedStrip = await readPanel(); - assert.equal(movedStrip.floating, true, 'the strip stays a window'); - near( - JSON.parse(movedStrip.stored).height, - chosen.height, - 2, - 'stored height while collapsed', - ); - await boot(); - await setCollapsed(false); - near( - (await readPanel()).height, - chosen.height, - 2, - 'height after expanding', - ); - console.log( - 'PASS: a collapsed window keeps its height through a drag and a reload', - ); - - const dock = await dockPanelByDoubleClick(page, PANEL_ID); - const snapped = await readPanel(); - assert.equal( - snapped.floating, - false, - `double-click snaps back (${describePress(dock)})`, - ); - assert.equal(snapped.inRail, true); - assert.equal(snapped.inlineWidth, ''); - assert.equal(snapped.inlineHeight, ''); - assert.equal(snapped.stored, null, 'the storage key is gone'); - console.log('PASS: header double-click returns the panel to the rail'); - - const renderErrors = await readRenderErrors(page); - assert.deepEqual( - [...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)], - [], - 'no page errors and no Cesium render-loop errors', - ); - console.log('PASS: no page errors and no render-loop errors'); - } catch (error) { - const dir = await saveFailureArtifacts({ - dir: ARTIFACT_DIR, - browser, - monitors, - error, - }).catch(() => null); - if (dir) console.error(`failure evidence saved in ${dir}/`); - throw error; - } finally { - await browser.close(); - } -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - console.error(error.stack || error.message); - process.exit(1); - }); -} diff --git a/scripts/qa-panelDrag.mjs b/scripts/qa-panelDrag.mjs deleted file mode 100644 index fa6cc0c3a..000000000 --- a/scripts/qa-panelDrag.mjs +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Header press-and-verify for the QA gates that lift a rail panel into a - * floating window and dock it again. The rail animates panel heights, so a - * point read once can be under another panel by the time the pointer goes - * down. Only a provable miss (a recorded press outside the header) is - * retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the - * panel's own movement cannot explain fails the run instead. - */ - -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Retries after the first attempt, and the pause before each. */ -export const PRESS_RETRIES = 2; -const RETRY_PAUSE_MS = 1000; - -/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */ -export function failOnRetryDefault( - env = process.env, - argv = process.argv.slice(2), -) { - return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry'); -} - -/** - * A failed attempt whose press landed outside the header. No recorded press - * is a failure, not a miss: a retry would only hide it. - * @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt - */ -export function pressMissed(attempt) { - return !attempt.done && attempt.pressed?.inHeader === false; -} - -/** Whether the panel's box changed by more than 2 px between two reads. */ -export function panelMoved(before, after) { - if (!before || !after) return false; - return ['left', 'top', 'width', 'height'].some( - (key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2, - ); -} - -/** The panel's box, rounded, as `left,top widthxheight`. */ -export function formatBox(box) { - if (!box) return 'unknown'; - const r = (value) => Math.round(value ?? NaN); - return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`; -} - -function panelBox(page, panelId) { - return page.evaluate((id) => { - const r = document.getElementById(id).getBoundingClientRect(); - return { left: r.left, top: r.top, width: r.width, height: r.height }; - }, panelId); -} - -export function headerPoint(page, panelId) { - return page.evaluate((id) => { - const title = document.querySelector(`#${id} .panel-header .panel-title`); - const r = title.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }, panelId); -} - -/** Name the element at a point, and whether it is the panel's header. */ -export function hitAt(page, point, panelId) { - return page.evaluate( - ({ x, y, id }) => { - const node = document.elementFromPoint(x, y); - if (!node) return { target: 'nothing', inHeader: false }; - return { - target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`, - inHeader: Boolean(node.closest(`#${id} .panel-header`)), - }; - }, - { ...point, id: panelId }, - ); -} - -/** - * Re-resolve the header point until it hits the header or the wait runs out - * (the press then records the miss). - */ -export async function headerTarget( - page, - panelId, - { tries = 20, intervalMs = 500 } = {}, -) { - let point = await headerPoint(page, panelId); - let hit = await hitAt(page, point, panelId); - for (let i = 0; i < tries && !hit.inHeader; i++) { - await delay(intervalMs); - point = await headerPoint(page, panelId); - hit = await hitAt(page, point, panelId); - } - const box = await panelBox(page, panelId); - return { point, hit, timedOut: !hit.inHeader, box }; -} - -/** - * Best effort: wait until the panel's box has held still for `stillMs`, so - * the header does not move between the hit check and the press. - */ -export async function waitForStill( - page, - panelId, - { stillMs = 300, timeout = 5000 } = {}, -) { - try { - await page.waitForFunction( - (id, ms) => { - const r = document.getElementById(id).getBoundingClientRect(); - const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); - const now = performance.now(); - if (window.__qaStillBox !== box) { - window.__qaStillBox = box; - window.__qaStillSince = now; - } - return now - window.__qaStillSince >= ms; - }, - { polling: 100, timeout }, - panelId, - stillMs, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Record the next pointerdown's target and the panel's box at that moment. */ -export function recordNextPress(page, panelId) { - return page.evaluate((id) => { - window.__qaPressBox = null; - window.addEventListener( - 'pointerdown', - (event) => { - const r = document.getElementById(id).getBoundingClientRect(); - const target = event.target; - window.__qaPressBox = { - left: r.left, - top: r.top, - width: r.width, - height: r.height, - target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`, - inHeader: Boolean(target.closest?.(`#${id} .panel-header`)), - }; - }, - { capture: true, once: true }, - ); - }, panelId); -} - -function readPress(page) { - return page.evaluate(() => window.__qaPressBox ?? null); -} - -export async function waitForFloating( - page, - panelId, - floating, - { timeout = 3000 } = {}, -) { - try { - await page.waitForFunction( - (id, want) => - document.getElementById(id).classList.contains('panel-floating') === - want, - { polling: 50, timeout }, - panelId, - floating, - ); - return true; - } catch (error) { - if (error?.name !== 'TimeoutError') throw error; - return false; - } -} - -/** Two paints: by then every layout change an input caused is on screen. */ -export function nextFrames(page) { - return page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ), - ); -} - -export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) { - await page.mouse.move(from.x, from.y); - await page.mouse.down(); - await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps }); - await page.mouse.move(from.x + dx, from.y + dy, { steps }); - await page.mouse.up(); - await nextFrames(page); -} - -export function describeRetry( - panelId, - attempt, - { round, retries, extra = '' }, -) { - const { hit, pressed, timedOut, still, box } = attempt; - return [ - `retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`, - `pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`, - `stillness ${still === false ? 'timed out' : 'held'}`, - `panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`, - ] - .concat(extra ? [extra] : []) - .join('; '); -} - -/** Retry `press` only while it provably misses the header. */ -async function pressUntilDecided( - page, - panelId, - press, - { - retries, - log, - note, - pauseMs = RETRY_PAUSE_MS, - failOnRetry = failOnRetryDefault(), - }, -) { - for (let round = 0; ; round++) { - const still = await waitForStill(page, panelId); - await recordNextPress(page, panelId); - const { point, hit, timedOut, box } = await headerTarget(page, panelId); - const done = await press(point); - const attempt = { - point, - hit, - timedOut, - still, - box, - done, - pressed: await readPress(page), - retries: round, - }; - if (round >= retries || !pressMissed(attempt)) return attempt; - const why = describeRetry(panelId, attempt, { - round, - retries, - extra: note ? await note() : '', - }); - if (failOnRetry && !panelMoved(box, attempt.pressed)) - throw new Error( - `QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`, - ); - log(`note: ${why}; retrying`); - log(`::warning title=Panel press retried::${why}`); - await delay(pauseMs); - } -} - -/** - * Lift a docked panel into a floating window with a header drag. - * @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>} - */ -export function liftPanelByHeader( - page, - panelId, - { - dx, - dy, - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await dragPointer(page, point, dx, dy); - return waitForFloating(page, panelId, true); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Dock a floating panel with a header double-click; same result as lifting. */ -export function dockPanelByDoubleClick( - page, - panelId, - { - retries = PRESS_RETRIES, - log = console.log, - note, - pauseMs, - failOnRetry, - } = {}, -) { - return pressUntilDecided( - page, - panelId, - async (point) => { - await page.mouse.click(point.x, point.y, { clickCount: 1 }); - await page.mouse.click(point.x, point.y, { clickCount: 2 }); - return waitForFloating(page, panelId, false); - }, - { retries, log, note, pauseMs, failOnRetry }, - ); -} - -/** Where an attempt pressed, for an assertion message. */ -export function describePress(attempt) { - const { point, hit, pressed } = attempt; - const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`; - if (!pressed) - return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`; - const n = attempt.retries || 0; - const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : ''; - return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`; -} diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 59b0584da..342a4aa2b 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -8,9 +8,8 @@ * because the bundle carries it; CI uses a dummy one. Fixture runs also stage * a rejected key and a keyless second server. * - * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry` - * (or QA_FAIL_ON_RETRY=1) fails a missed header press unless the panel moved. - * A failed run saves evidence under qa-artifacts/street-level/. + * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. A failed run saves + * evidence under qa-artifacts/street-level/. */ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; @@ -18,12 +17,6 @@ import { LAYER_STATE_REGISTRY } from '../src/data/layerState.js'; import { tileBounds } from '../src/layers/streetLevel/tileMath.js'; import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs'; import { COVERAGE_MOVE_DEBOUNCE_MS } from '../src/layers/streetLevel/providers/mapillary/policy.js'; -import { - describePress, - dockPanelByDoubleClick, - liftPanelByHeader, - waitForStill, -} from './qa-panelDrag.mjs'; import { hookRenderErrors, readRenderErrors, @@ -339,6 +332,38 @@ async function clearFirstRun(page) { }); } +/** + * Best effort: wait until the panel's box has held still for `stillMs`, so a + * point read from it does not move before the press. + */ +async function waitForStill( + page, + panelId, + { stillMs = 300, timeout = 5000 } = {}, +) { + try { + await page.waitForFunction( + (id, ms) => { + const r = document.getElementById(id).getBoundingClientRect(); + const box = [r.left, r.top, r.width, r.height].map(Math.round).join(); + const now = performance.now(); + if (window.__qaStillBox !== box) { + window.__qaStillBox = box; + window.__qaStillSince = now; + } + return now - window.__qaStillSince >= ms; + }, + { polling: 100, timeout }, + panelId, + stillMs, + ); + return true; + } catch (error) { + if (error?.name !== 'TimeoutError') throw error; + return false; + } +} + /** * Click a panel control once the panel holds still and the control is what * lies under its centre: the rail animates panel heights, so a point read @@ -528,7 +553,6 @@ async function main() { const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; const fixtures = args.includes('--fixtures'); const strict = args.includes('--strict'); - if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1'; const browser = await puppeteer.launch({ headless: true, executablePath: @@ -1425,12 +1449,6 @@ async function main() { await stacks.set(original); }, ); - const expanded = () => - page.evaluate(() => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); await step( 'EXPAND opens a modal dialog (the rest of inert) and Esc returns focus to the button', async () => { @@ -1512,209 +1530,56 @@ async function main() { assert.equal(after.sequence.selectedId, null); }, ); - const floating = () => - page.$eval('#street-level-panel', (node) => - node.classList.contains('panel-floating'), - ); - const center = (selector) => - page.$eval(selector, (node) => { - const r = node.getBoundingClientRect(); - return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; - }); - // Right after a dock the rail is still animating, so the shared helpers - // re-resolve the header and retry only a press that provably missed it. - const floatPanel = async () => { - const attempt = await liftPanelByHeader(page, PANEL_ID, { - dx: -400, - dy: 80, - }); - assert.equal( - attempt.done, - true, - `a header drag lifts the panel out of the rail (${describePress(attempt)})`, - ); - assert.equal(await floating(), true); - }; - await step( - 'the panel floats on a header drag, resizes, and the viewer takes the room', - async () => { - await openById(firstImageId); - await floatPanel(); - const viewerHeight = () => - page.$eval('#sl-viewer', (node) => - Math.round(node.getBoundingClientRect().height), - ); - const before = await viewerHeight(); - const grip = await center('#street-level-panel .panel-resize-grip'); - await page.mouse.move(grip.x, grip.y); - await page.mouse.down(); - await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 }); - await page.mouse.up(); - await settle( - page, - (height) => - document.getElementById('sl-viewer').getBoundingClientRect() - .height > - height + 60, - before, - ); - const after = await viewerHeight(); - assert.ok( - after > before + 60, - `viewer grew with the window (${before} → ${after}px)`, - ); - assert.equal( - await page.$eval( - '.sl-settings', - (node) => getComputedStyle(node).overflowY, - ), - 'auto', - 'only the settings block scrolls', - ); - }, - ); - await step( - 'SHRINK after EXPAND docks the window back in the rail at its default size, viewer inside the panel', - async () => { - await clickPanelControl(page, '#sl-viewer-expand'); - await settle(page, () => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await expanded(), true, 'expanded'); - await clickPanelControl(page, '#sl-viewer-expand'); - await settle( - page, - () => - !document - .getElementById('street-level-panel') - .classList.contains('panel-floating') && - !document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - assert.equal(await floating(), false, 'docked again'); - const style = await page.$eval('#street-level-panel', (node) => ({ - width: node.style.width, - height: node.style.height, - parent: node.parentElement.id, - })); - assert.deepEqual(style, { - width: '', - height: '', - parent: 'right-context-rail', - }); - const home = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - return { - inPanel: Boolean(wrap.closest('#street-level-panel')), - onBody: wrap.parentElement === document.body, - }; - }); - assert.deepEqual( - home, - { inPanel: true, onBody: false }, - 'the viewer returned into the panel', - ); - }, - ); - await step('a header double-click docks a floating window', async () => { - await floatPanel(); - const attempt = await dockPanelByDoubleClick(page, PANEL_ID); - assert.equal( - attempt.done, - true, - `docked again (${describePress(attempt)})`, - ); - }); - await step( - 'collapsing a floating window docks it as the rail strip', - async () => { - // The double-click just docked a panel showing a photo: let the photo - // and its sequence finish loading before pressing the header again. - await uiUntil((u) => !u.street.loading && !u.sequence.loading); - await floatPanel(); - await clickPanelControl( - page, - '.panel-collapse-btn[data-collapse-target="street-level-panel"]', - ); - await settle(page, () => { - const node = document.getElementById('street-level-panel'); - return ( - node.classList.contains('collapsed') && - !node.classList.contains('panel-floating') - ); - }); - const state = await page.$eval('#street-level-panel', (node) => ({ - floating: node.classList.contains('panel-floating'), - collapsed: node.classList.contains('collapsed'), - height: node.style.height, - })); - assert.deepEqual(state, { - floating: false, - collapsed: true, - height: '', - }); - }, - ); - await step( - 'on a phone the whole photo fits in the docked panel', - async () => { - // Width alone drives the phone layout; toggling isMobile would reload. - await page.setViewport({ width: 390, height: 844 }); - await settle(page, () => innerWidth === 390); - if ( - await page.$eval('#street-level-panel', (node) => - node.classList.contains('collapsed'), - ) + await step('on a phone the whole photo fits in the panel', async () => { + // Width alone drives the phone layout; toggling isMobile would reload. + await page.setViewport({ width: 390, height: 844 }); + await settle(page, () => innerWidth === 390); + if ( + await page.$eval('#street-level-panel', (node) => + node.classList.contains('collapsed'), ) - await expandStrip(); - await openById(firstImageId); - const measure = () => - page.evaluate(() => { - const inner = document - .querySelector('.street-level-panel-inner') - .getBoundingClientRect(); - const meta = document - .getElementById('sl-image-meta') - .getBoundingClientRect(); - const viewer = document - .getElementById('sl-viewer') - .getBoundingClientRect(); - return { - cut: Math.round(meta.bottom - inner.bottom), - top: Math.round(viewer.top - inner.top), - height: Math.round(viewer.height), - overflowX: document.documentElement.scrollWidth > innerWidth, - }; - }); - // The panel settles its phone layout over a frame or two. - await settle(page, () => { + ) + await expandStrip(); + await openById(firstImageId); + const measure = () => + page.evaluate(() => { const inner = document .querySelector('.street-level-panel-inner') .getBoundingClientRect(); const meta = document .getElementById('sl-image-meta') .getBoundingClientRect(); - return ( - meta.bottom - inner.bottom <= 1 && - document.getElementById('sl-viewer').getBoundingClientRect() - .height >= 100 - ); + const viewer = document + .getElementById('sl-viewer') + .getBoundingClientRect(); + return { + cut: Math.round(meta.bottom - inner.bottom), + top: Math.round(viewer.top - inner.top), + height: Math.round(viewer.height), + overflowX: document.documentElement.scrollWidth > innerWidth, + }; }); - const fit = await measure(); - assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); - assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); - assert.equal(fit.overflowX, false, 'no sideways scroll'); - await page.setViewport(VIEWPORTS[0]); - await settle( - page, - (width) => innerWidth === width, - VIEWPORTS[0].width, + // The panel settles its phone layout over a frame or two. + await settle(page, () => { + const inner = document + .querySelector('.street-level-panel-inner') + .getBoundingClientRect(); + const meta = document + .getElementById('sl-image-meta') + .getBoundingClientRect(); + return ( + meta.bottom - inner.bottom <= 1 && + document.getElementById('sl-viewer').getBoundingClientRect() + .height >= 100 ); - }, - ); + }); + const fit = await measure(); + assert.ok(fit.cut <= 1, `photo and caption fit (${fit.cut}px cut)`); + assert.ok(fit.height >= 100, `viewer stays usable (${fit.height}px)`); + assert.equal(fit.overflowX, false, 'no sideways scroll'); + await page.setViewport(VIEWPORTS[0]); + await settle(page, (width) => innerWidth === width, VIEWPORTS[0].width); + }); await step( 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', async () => { diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index b727e6d12..2254ea24e 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,11 +45,7 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - // A panel floating out of the rail (portable mode) is exempt from exclusivity. - assert.match( - css, - /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, - ); + assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index de1878a79..e8df49460 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -54,14 +54,13 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai /for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/, ); for (const rule of [ - '#right-context-rail > #street-level-panel:not(.panel-floating)', - '#right-context-rail #street-level-panel.collapsed:not(.panel-floating)', + '#right-context-rail > #street-level-panel', + '#right-context-rail #street-level-panel.collapsed', ]) assert.ok(css.includes(rule), `layers.css names ${rule}`); - // Rail placement never applies to the panel once it floats as a window. assert.match( css, - /#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\):not\(\.panel-floating\)/, + /#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\)/, ); }); @@ -138,15 +137,14 @@ test('on phones the viewer is sized from the rail band, not its aspect ratio', ( panelCss, /@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/, ); - // Floating and expanded viewers must not inherit the phone height. - for (const selector of [ - ".panel-floating\\[style\\*='height'\\] \\.sl-viewer", - '\\.sl-viewer-wrap-expanded \\.sl-viewer', - ]) - assert.match(panelCss, new RegExp(`${selector} \\{[^}]*height: auto`)); + // The expanded viewer must not inherit the phone height. + assert.match( + panelCss, + /\.sl-viewer-wrap-expanded \.sl-viewer \{[^}]*height: auto/, + ); }); -test('the viewer comes first and the panel is a portable, resizable window', () => { +test('the viewer comes first, with follow in its toolbar', () => { const controlsBlock = html.slice(html.indexOf('
')); assert.ok( controlsBlock.indexOf('id="sl-viewer-wrap"') < @@ -158,35 +156,6 @@ test('the viewer comes first and the panel is a portable, resizable window', () /id="sl-follow-btn"/, 'follow lives in the viewer toolbar', ); - const position = read('src/ui/panelPositionControls.js'); - assert.match( - position, - /id: 'street-level-panel',[\s\S]*?portable: true,[\s\S]*?min: \{ width: 320, height: 280 \}/, - ); - assert.match( - panelCss, - /\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/, - ); - // Collapsing a floating panel, or SHRINK / Esc on the expanded viewer, - // docks it rather than leaving a window over the globe. - assert.match( - position, - /id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/, - ); - assert.match( - read('src/ui/panelChrome.js'), - /classList\.toggle\('collapsed', nextCollapsed\);[\s\S]{0,200}?if \(explicit && !restore\)\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/, - ); - assert.match( - controls, - /setViewerExpanded\(!this\.isViewerExpanded\(\), \{ dock: true \}\)/, - ); - assert.match(controls, /this\.setViewerExpanded\(false, \{ dock: true \}\)/); - assert.match(controls, /if \(!on && dock\) this\.actions\.dockPanel\?\.\(\)/); - assert.match( - read('src/ui/applicationShell.js'), - /panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/, - ); }); test('Clean View, recording and the cockpit hide the expanded viewer on (P2-5)', () => { diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs deleted file mode 100644 index 586f22c08..000000000 --- a/src/tooling/panelResizeQa.test.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - driftedEdges, - growthDelta, - handlePoint, - MIN_SIZE, - pinnedEdges, - STORAGE_KEY, -} from '../../scripts/qa-panel-resize.mjs'; -import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; - -test('the harness targets the key and minimums the controls use', () => { - assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); - assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); -}); - -test('pinned edges are exactly the sides a direction does not name', () => { - assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); - assert.deepEqual(pinnedEdges('se'), ['top', 'left']); - assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); - for (const dir of RESIZE_DIRECTIONS) - assert.equal(pinnedEdges(dir).length, 4 - dir.length); -}); - -test('growth deltas push each named edge outward', () => { - assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); - assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); - assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); - assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); -}); - -test('handle points land inside the strip or corner for every direction', () => { - const rect = { left: 100, top: 200, width: 400, height: 300 }; - rect.right = rect.left + rect.width; - rect.bottom = rect.top + rect.height; - assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); - assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); - assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); - assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); - for (const dir of RESIZE_DIRECTIONS) { - const { x, y } = handlePoint(rect, dir); - assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); - assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); - } -}); - -test('drift detection reports only pinned edges that moved', () => { - const before = { left: 100, top: 100, width: 400, height: 300 }; - const grewEast = { left: 100, top: 100, width: 440, height: 300 }; - assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); - const slidWest = { left: 60, top: 100, width: 440, height: 300 }; - assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); - assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); - const wobble = { left: 101, top: 100, width: 439, height: 300 }; - assert.deepEqual(driftedEdges(before, wobble, 'e'), []); -}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index ec9450853..9f83b1c2b 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -124,189 +124,6 @@ test('the gate requires the server’s real Mapillary status route', async () => ); }); -/* ── Header press-and-verify shared with the panel gate ───────────────── */ - -/** - * A page stand-in that runs page functions in Node against a fake document. - * The hit check always sees the header; `presses` says where each pointerdown - * lands ('header', 'other', 'moved' or 'none'). - */ -function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { - const state = { floating, downs: 0, last: null, dragging: false, shift: 0 }; - const listeners = []; - const node = (tagName, id, classes, inHeader) => ({ - tagName, - id, - classList: classes, - closest: (selector) => - inHeader && selector.includes('.panel-header') ? {} : null, - }); - const header = node('DIV', '', ['panel-title'], true); - const other = node('SECTION', 'weather-panel', ['panel'], false); - const rect = (left, top, width, height) => () => ({ - left, - top, - width, - height, - }); - const document = { - querySelector: () => ({ getBoundingClientRect: rect(120, 210, 100, 20) }), - getElementById: () => ({ - getBoundingClientRect: () => rect(100, 200 + state.shift, 300, 400)(), - classList: { - contains: (name) => name === 'panel-floating' && state.floating, - }, - }), - elementFromPoint: () => header, - }; - const window = { - addEventListener: (type, listener) => listeners.push(listener), - }; - const inPage = (fn, ...args) => { - const saved = { document: globalThis.document, window: globalThis.window }; - Object.assign(globalThis, { document, window }); - try { - return fn(...args); - } finally { - Object.assign(globalThis, saved); - } - }; - const evaluate = async (fn, ...args) => inPage(fn, ...args); - const press = () => { - state.last = presses[state.downs++] ?? 'header'; - // 'none': the pointerdown never reaches the page. - if (state.last === 'none') return; - const target = state.last === 'header' ? header : other; - // 'moved': the panel's layout shifted just before the press landed. - if (state.last === 'moved') state.shift = -116; - for (const listener of listeners.splice(0)) inPage(listener, { target }); - state.shift = 0; - }; - return { - state, - evaluate, - async waitForFunction(fn, options, ...args) { - for (let i = 0; i < 3; i++) { - if (await evaluate(fn, ...args)) return; - await new Promise((resolve) => setTimeout(resolve, 1)); - } - throw Object.assign(new Error('timed out'), { name: 'TimeoutError' }); - }, - mouse: { - async move() {}, - async down() { - press(); - state.dragging = true; - }, - async up() { - if (state.dragging && state.last === 'header' && !stuck) - state.floating = true; - state.dragging = false; - }, - async click(x, y, { clickCount }) { - if (clickCount === 1) press(); - else if (state.last === 'header' && !stuck) state.floating = false; - }, - }, - }; -} - -test('a header press is retried only when it provably missed the header', async () => { - const { pressMissed } = await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(pressMissed({ done: true, pressed: null }), false); - assert.equal( - pressMissed({ done: false, pressed: { inHeader: true } }), - false, - ); - assert.equal( - pressMissed({ done: false, pressed: { inHeader: false } }), - true, - ); - // No recorded press is a failure, not a miss to retry. - assert.equal(pressMissed({ done: false, pressed: null }), false); -}); - -test('a lift whose press the rail moved off the header is retried, then lifts', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['other', 'header'] }); - const notes = []; - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: (line) => notes.push(line), - pauseMs: 0, - }); - assert.equal(attempt.done, true); - assert.equal(page.state.downs, 2); - assert.equal(notes.length, 2, 'a note and a GitHub warning'); - assert.match(notes[0], /missed the street-level-panel header/); - assert.match(notes[0], /section#weather-panel\.panel/, 'names what it hit'); - assert.match(notes[0], /pre-press hit check passed/); - assert.match(notes[0], /stillness (held|timed out)/); - assert.match( - notes[0], - /panel box at hit check 100,200 300x400 -> at press 100,200 300x400/, - ); - assert.match(notes[1], /^::warning title=Panel press retried::retry 1\/2/); - assert.match(describePress(attempt), /in header: true, after 1 retry$/); -}); - -test('a press in the header that does not lift fails at once, naming the target', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ stuck: true }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => assert.fail('no retry'), - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(page.state.downs, 1); - assert.match(describePress(attempt), /on div\.panel-title, in header: true/); -}); - -test('presses that keep missing stop after the retries and report the miss', async () => { - const { liftPanelByHeader, PRESS_RETRIES } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['other', 'other', 'other', 'other'] }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => {}, - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(page.state.downs, PRESS_RETRIES + 1); - assert.equal(attempt.pressed.inHeader, false); -}); - -test('a header double-click docks, waiting on the docked state', async () => { - const { dockPanelByDoubleClick } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ floating: true, presses: ['other', 'header'] }); - const attempt = await dockPanelByDoubleClick(page, 'street-level-panel', { - log: () => {}, - pauseMs: 0, - }); - assert.equal(attempt.done, true); - assert.equal(page.state.floating, false); - assert.equal(page.state.downs, 2, 'the missed first press was retried'); -}); - -test('both panel gates press the header through the shared helper', () => { - for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { - const source = fs.readFileSync( - new URL(`../../scripts/${script}`, import.meta.url), - 'utf8', - ); - assert.match(source, /from '\.\/qa-panelDrag\.mjs'/, script); - assert.match(source, /liftPanelByHeader\(page, PANEL_ID/, script); - assert.match(source, /dockPanelByDoubleClick\(page, PANEL_ID/, script); - } -}); - /* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */ test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => { @@ -527,80 +344,17 @@ test('--strict accepts only the documented skips', () => { ); }); -test('a press that records no pointerdown fails at once, without a retry', async () => { - const { liftPanelByHeader, describePress } = - await import('../../scripts/qa-panelDrag.mjs'); - const page = fakePanelPage({ presses: ['none', 'header'] }); - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => assert.fail('no retry'), - pauseMs: 0, - }); - assert.equal(attempt.done, false); - assert.equal(attempt.pressed, null); - assert.equal(page.state.downs, 1); - assert.match(describePress(attempt), /no pointerdown reached the page/); -}); - -test('QA_FAIL_ON_RETRY turns a retry into a failure that says why', async () => { - const { liftPanelByHeader, failOnRetryDefault } = - await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(failOnRetryDefault({ QA_FAIL_ON_RETRY: '1' }, []), true); - assert.equal(failOnRetryDefault({}, ['--fail-on-retry']), true); - assert.equal(failOnRetryDefault({}, []), false); - const page = fakePanelPage({ presses: ['other', 'header'] }); - await assert.rejects( - liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: () => {}, - pauseMs: 0, - failOnRetry: true, - }), - /QA_FAIL_ON_RETRY=1 .*missed the street-level-panel header.*pre-press hit check passed/, +test('the gate saves failure evidence and fails on render-loop errors', () => { + const source = fs.readFileSync( + new URL('../../scripts/qa-street-level.mjs', import.meta.url), + 'utf8', ); - assert.equal(page.state.downs, 1); + assert.match(source, /saveFailureArtifacts\(/); + assert.match(source, /hookRenderErrors\(/); + assert.match(source, /readRenderErrors\(/); }); -test('QA_FAIL_ON_RETRY retries, with a warning, a miss the panel moving explains', async () => { - const { liftPanelByHeader, panelMoved } = - await import('../../scripts/qa-panelDrag.mjs'); - assert.equal(panelMoved({ left: 0, top: 382 }, { left: 0, top: 266 }), true); - assert.equal(panelMoved({ left: 0, top: 382 }, { left: 1, top: 383 }), false); - const page = fakePanelPage({ presses: ['moved', 'header'] }); - const lines = []; - const attempt = await liftPanelByHeader(page, 'street-level-panel', { - dx: -400, - dy: 80, - log: (line) => lines.push(line), - pauseMs: 0, - failOnRetry: true, - }); - assert.equal(attempt.done, true, 'the second press lifted it'); - assert.equal(page.state.downs, 2); - assert.ok( - lines.some((line) => - /::warning title=Panel press retried::.*-> at press 100,84/.test(line), - ), - lines.join('\n'), - ); -}); - -test('both gates save failure evidence and fail on render-loop errors', () => { - for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) { - const source = fs.readFileSync( - new URL(`../../scripts/${script}`, import.meta.url), - 'utf8', - ); - assert.match(source, /saveFailureArtifacts\(/, script); - assert.match(source, /hookRenderErrors\(/, script); - assert.match(source, /readRenderErrors\(/, script); - assert.match(source, /--fail-on-retry/, script); - } -}); - -test('CI runs the gates hermetically against a production build, strict, with evidence', () => { +test('CI runs the gate hermetically against a production build, strict, with evidence', () => { const ci = fs.readFileSync( new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8', @@ -628,9 +382,4 @@ test('CI runs the gates hermetically against a production build, strict, with ev /if: failure\(\)[\s\S]*actions\/upload-artifact@[0-9a-f]{40}/, ); assert.match(gate, /path: qa-artifacts\//); - assert.match( - gate, - /QA_FAIL_ON_RETRY: '1'/, - 'a missed header press fails CI now that the rail settles', - ); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index d400b54c2..a9ba8022d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -105,7 +105,6 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), - _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -960,7 +959,6 @@ export class StyleManager extends ShellFacade { this._dataManager?.setLayerParams('street-level', params, options), setPanelCollapsed: (collapsed, options) => this.setPanelCollapsed('street-level-panel', collapsed, options), - dockPanel: () => this._panelChrome.dockPanel('street-level-panel'), showToast: (message) => this._showToast(message), // Lets the panel open for a user, voice or tool switch-on, not a restore. subscribeEnableRequests: (listener) => @@ -1516,13 +1514,6 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } - /** A portable panel was resized or docked: refit any viewport inside it. */ - _onPanelResized(panelId) { - if (panelId === 'cctv-panel') this._syncCctvPanelViewport(); - if (panelId === 'street-level-panel') - this._streetLevelControls?.onPanelResized(); - } - /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs index 911e2cab1..ae0256d4c 100644 --- a/src/ui/cyberPanelDisclosure.test.mjs +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -85,16 +85,3 @@ test('non-Cyber explicit panels and restored preferences retain their existing p } finally { globalThis.document = prior; } } }); - -test('opening a floating Cyber window leaves the docked rail panels open', () => { - const f = fixture(), prior = globalThis.document; - globalThis.document = f.doc; - try { - f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true }); - f.cctv.classList.add('panel-floating'); - f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true }); - assert.equal(f.cctv.classList.contains('collapsed'), false); - assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open'); - assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed)); - } finally { globalThis.document = prior; } -}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 284d2b1b2..5a671e453 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -63,7 +63,6 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), - onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -147,7 +146,6 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); - this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { @@ -388,11 +386,6 @@ export class PanelChrome { this.shareLinkManager?.onPanelStateChange?.(); } - /** @returns {boolean} Whether the panel was floating and is now docked. */ - dockPanel(panelId) { - return this._panelPosition?.dockPanel?.(panelId) === true; - } - setPanelCollapsed( panelId, collapsed, @@ -415,20 +408,17 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed'); // A user opening a Cyber rail panel owns the whole accordion, including // peers that were only presentation-collapsed during a saved-state restore. - // A floating window is outside the accordion both ways. if ( explicit && !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack && - !panelEl.classList.contains('panel-floating') + panelEl.parentElement === this._rightPanelStack ) { for (const peer of this._rightPanelStack.children) { if ( peer !== panelEl && peer.matches('[data-panel-id]') && - !peer.classList.contains('panel-floating') && !peer.hidden ) { this.setPanelCollapsed(peer.id, true, { @@ -556,10 +546,6 @@ export class PanelChrome { } } panelEl.classList.toggle('collapsed', nextCollapsed); - // Only a user's own collapse docks a floating window; restores, cockpit - // entry and accordion peers keep its place. - if (explicit && !restore) - this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed); if ( nextCollapsed && this.cockpitView?.active && diff --git a/src/ui/panelDock.test.mjs b/src/ui/panelDock.test.mjs deleted file mode 100644 index 52716a54b..000000000 --- a/src/ui/panelDock.test.mjs +++ /dev/null @@ -1,580 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { PanelPositionControls } from './panelPositionControls.js'; - -/** Just enough DOM for PanelPositionControls' portable-panel paths. */ -function fixture() { - const saved = Object.fromEntries( - ['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map( - (key) => [key, globalThis[key]], - ), - ); - const store = new Map(); - const element = () => { - const classes = new Set(); - const style = { - removeProperty(name) { - // Like CSSStyleDeclaration: 'z-index' also clears style.zIndex. - delete this[name]; - delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())]; - }, - getPropertyValue: () => '', - }; - return Object.assign(new EventTarget(), { - id: '', - style, - dataset: {}, - classList: { - add: (...names) => names.forEach((name) => classes.add(name)), - remove: (...names) => names.forEach((name) => classes.delete(name)), - contains: (name) => classes.has(name), - toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), - }, - querySelector: () => null, - querySelectorAll: () => [], - closest: () => null, - setAttribute() {}, - removeAttribute() {}, - appendChild() {}, - remove() {}, - getBoundingClientRect: () => ({ - left: 100, - top: 80, - width: 360, - height: 420, - right: 460, - bottom: 500, - }), - }); - }; - globalThis.document = Object.assign(new EventTarget(), { - getElementById: () => null, - querySelectorAll: () => [], - createElement: () => element(), - }); - globalThis.window = Object.assign(new EventTarget(), { - innerWidth: 1440, - innerHeight: 900, - }); - globalThis.localStorage = { - getItem: (key) => (store.has(key) ? store.get(key) : null), - setItem: (key, value) => store.set(key, String(value)), - removeItem: (key) => store.delete(key), - }; - const calls = { layout: 0, resized: [] }; - const owner = new PanelPositionControls({ - syncPanelCollapseButton() {}, - layoutRightPanels: () => calls.layout++, - syncCctvPanelViewport() {}, - showToast() {}, - onPanelResized: (id) => calls.resized.push(id), - }); - return { - owner, - element, - store, - calls, - restore() { - owner.destroy(); - Object.assign(globalThis, saved); - }, - }; -} - -function floatingPanel(f, id, { dockOnCollapse = false } = {}) { - const panel = f.element(); - const handle = f.element(); - panel.id = id; - panel.classList.add('panel-floating', 'panel-draggable'); - Object.assign(panel.style, { - left: '100px', - top: '80px', - width: '360px', - height: '420px', - }); - f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}'); - f.owner._portablePanels.set(id, { - panel, - min: { width: 320, height: 280 }, - dockOnCollapse, - }); - f.owner._makePanelDraggable(id, panel, handle); - return { panel, handle }; -} - -/** A left-button header press; `timeStamp` is a read-only getter on Event. */ -function press(handle, { timeStamp, clientX = 200, clientY = 90 }) { - const event = new Event('pointerdown', { cancelable: true }); - for (const [key, value] of Object.entries({ - button: 0, - clientX, - clientY, - timeStamp, - })) - Object.defineProperty(event, key, { value }); - handle.dispatchEvent(event); -} - -test('dockPanel returns a floating window to its rail and forgets its place', () => { - const f = fixture(); - try { - const { panel } = floatingPanel(f, 'street-level-panel'); - assert.equal(f.owner.dockPanel('street-level-panel'), true); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.style.width, undefined); - assert.equal(panel.style.height, undefined); - assert.equal( - f.store.has('godsEyeView.v8.panelPos.street-level-panel'), - false, - ); - assert.deepEqual(f.calls.resized, ['street-level-panel']); - assert.equal( - f.owner.dockPanel('street-level-panel'), - false, - 'already docked', - ); - assert.equal(f.owner.dockPanel('unknown'), false); - } finally { - f.restore(); - } -}); - -test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => { - const f = fixture(); - try { - const street = floatingPanel(f, 'street-level-panel', { - dockOnCollapse: true, - }); - const cctv = floatingPanel(f, 'cctv-panel'); - f.owner.onPanelCollapsed('street-level-panel', false); - assert.equal( - street.panel.classList.contains('panel-floating'), - true, - 'expanding keeps it', - ); - f.owner.onPanelCollapsed('cctv-panel', true); - assert.equal( - cctv.panel.classList.contains('panel-floating'), - true, - 'CCTV did not opt in', - ); - f.owner.onPanelCollapsed('street-level-panel', true); - assert.equal(street.panel.classList.contains('panel-floating'), false); - } finally { - f.restore(); - } -}); - -test('two quick header presses snap a floating panel back; slow or distant ones do not', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - press(handle, { timeStamp: 1000 }); - window.dispatchEvent(new Event('pointerup')); - press(handle, { timeStamp: 1600 }); - window.dispatchEvent(new Event('pointerup')); - assert.equal( - panel.classList.contains('panel-floating'), - true, - '600 ms apart is two clicks', - ); - press(handle, { timeStamp: 1700, clientX: 260 }); - window.dispatchEvent(new Event('pointerup')); - assert.equal( - panel.classList.contains('panel-floating'), - true, - '60 px apart is two clicks', - ); - press(handle, { timeStamp: 1900, clientX: 262 }); - assert.equal( - panel.classList.contains('panel-floating'), - false, - 'a double press docks', - ); - } finally { - f.restore(); - } -}); - -test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => { - const f = fixture(); - try { - const panel = f.element(); - panel.id = 'street-level-panel'; - f.owner._portablePanels.set('street-level-panel', { - panel, - min: { width: 320, height: 280 }, - dockOnCollapse: true, - }); - f.store.set( - 'godsEyeView.v8.panelPos.street-level-panel', - JSON.stringify({ - left: 900, - top: 200, - width: 420, - height: 600, - floating: true, - }), - ); - f.owner._restorePanelPosition('street-level-panel', panel); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`); - f.owner.dockPanel('street-level-panel'); - assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion'); - } finally { - f.restore(); - } -}); - -test('renumbering the z ladder never drops a window below the docked panels', () => { - const f = fixture(); - try { - const windows = [f.element(), f.element(), f.element()]; - windows.forEach((node, index) => { - node.classList.add('panel-draggable'); - node.style.zIndex = String(130 + index); - }); - globalThis.document.querySelectorAll = (selector) => - selector === '.panel-draggable' ? windows : []; - f.owner._panelZCounter = 139; - const top = f.element(); - top.classList.add('panel-draggable'); - f.owner._promotePanelZ(top); - for (const node of [...windows, top]) - assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`); - assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex)); - } finally { - f.restore(); - } -}); - -/** A portable panel still in its rail, with its header and resize handles. */ -function dockedPanel(f, id) { - const panel = f.element(); - const handle = f.element(); - panel.id = id; - const portable = { panel, min: { width: 320, height: 280 } }; - f.owner._portablePanels.set(id, portable); - f.owner._makePanelDraggable(id, panel, handle); - f.owner._makePanelResizable(id, panel, portable.min); - const edges = Object.fromEntries( - f.owner._resizeHandles.map((node) => [node.dataset.dir, node]), - ); - return { panel, handle, edges }; -} - -/** A pointer event; `pointerType` defaults to what a mouse reports. */ -function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) { - const event = new Event(type, { cancelable: true }); - for (const [key, value] of Object.entries({ - button: 0, - clientX: x, - clientY: y, - pointerType, - })) - Object.defineProperty(event, key, { value }); - target.dispatchEvent(event); - return event; -} - -const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel'; - -test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => { - const f = fixture(); - try { - const { panel, edges } = dockedPanel(f, 'street-level-panel'); - pointer(edges.w, 'pointerdown', { x: 101, y: 200 }); - pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click - pointer(window, 'pointerup', { x: 103, y: 201 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.style.width, undefined, 'no size was pinned'); - assert.equal(f.store.has(POSITION_KEY), false); - assert.deepEqual(f.calls.resized, []); - - // A real resize still lifts it out and remembers the window. - pointer(edges.w, 'pointerdown', { x: 101, y: 200 }); - pointer(window, 'pointermove', { x: 61, y: 200 }); - pointer(window, 'pointerup', { x: 61, y: 200 }); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true); - } finally { - f.restore(); - } -}); - -test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => { - const f = fixture(); - try { - const { panel, handle, edges } = dockedPanel(f, 'street-level-panel'); - const press = pointer(handle, 'pointerdown', { pointerType: 'touch' }); - assert.equal(press.defaultPrevented, false, 'the swipe can scroll'); - pointer(window, 'pointermove', { y: 190, pointerType: 'touch' }); - pointer(window, 'pointerup', { y: 190, pointerType: 'touch' }); - pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' }); - pointer(window, 'pointermove', { y: 600, pointerType: 'touch' }); - pointer(window, 'pointerup', { y: 600, pointerType: 'touch' }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(f.store.has(POSITION_KEY), false); - } finally { - f.restore(); - } -}); - -test('a pointercancel mid-drag saves nothing and puts the panel back', () => { - const f = fixture(); - try { - const { panel, handle, edges } = dockedPanel(f, 'street-level-panel'); - pointer(handle, 'pointerdown', {}); - pointer(window, 'pointermove', { x: 260, y: 140 }); - assert.equal(panel.classList.contains('panel-floating'), true, 'lifted'); - pointer(window, 'pointercancel', { x: 260, y: 140 }); - assert.equal(panel.classList.contains('panel-floating'), false, 'docked'); - assert.equal(f.store.has(POSITION_KEY), false); - - pointer(edges.e, 'pointerdown', { x: 459 }); - pointer(window, 'pointermove', { x: 520 }); - pointer(window, 'pointercancel', { x: 520 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(f.store.has(POSITION_KEY), false); - } finally { - f.restore(); - } -}); - -test('a pointercancel while dragging a floating window returns it to where it was', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel'); - pointer(handle, 'pointerdown', {}); - pointer(window, 'pointermove', { x: 400, y: 300 }); - assert.notEqual(panel.style.left, '100px', 'it moved'); - pointer(window, 'pointercancel', { x: 400, y: 300 }); - assert.equal(panel.style.left, '100px'); - assert.equal(panel.style.top, '80px'); - assert.equal(panel.classList.contains('panel-floating'), true); - assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored); - } finally { - f.restore(); - } -}); - -test('a header press that travels under the drag threshold is a click, not a lift', () => { - const f = fixture(); - try { - const { panel, handle } = dockedPanel(f, 'street-level-panel'); - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 202, y: 90 }); - pointer(window, 'pointerup', { x: 202, y: 90 }); - assert.equal(panel.classList.contains('panel-floating'), false); - assert.equal(panel.classList.contains('panel-dragging'), false); - assert.equal(f.store.has(POSITION_KEY), false); - assert.equal(f.calls.layout, 0, 'the rail was not re-laid out'); - assert.deepEqual(f.calls.resized, []); - } finally { - f.restore(); - } -}); - -/** - * Both portable panels wired through _initPanelDrag. Headers are the top - * 36 px; `obstacles` stand in for the command dock and voice pill. - * ResizeObserver callbacks run on `observe()`. - */ -function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) { - const observers = []; - globalThis.ResizeObserver = class { - constructor(callback) { - this.callback = callback; - this.targets = []; - observers.push(this); - } - observe(target) { - this.targets.push(target); - } - disconnect() {} - }; - const nodes = new Map(); - const panels = {}; - for (const id of ['cctv-panel', 'street-level-panel']) { - const panel = f.element(); - const handle = f.element(); - const natural = { width: 360, height: 420, ...box[id] }; - panel.id = id; - panel.querySelector = (selector) => - selector === '.panel-header' ? handle : null; - panel.getBoundingClientRect = () => { - const left = parseFloat(panel.style.left) || 0; - const top = parseFloat(panel.style.top) || 0; - const width = parseFloat(panel.style.width) || natural.width; - const height = parseFloat(panel.style.height) || natural.height; - return { - left, - top, - width, - height, - right: left + width, - bottom: top + height, - }; - }; - handle.getBoundingClientRect = () => { - const rect = panel.getBoundingClientRect(); - return { ...rect, height: 36, bottom: rect.top + 36 }; - }; - if (stored[id]) - f.store.set( - `godsEyeView.v8.panelPos.${id}`, - JSON.stringify({ floating: true, ...stored[id] }), - ); - nodes.set(id, panel); - panels[id] = { panel, handle, natural }; - } - const obstacle = (id, rect) => - nodes.set(id, { - getBoundingClientRect: () => ({ - ...rect, - width: rect.right - rect.left, - height: rect.bottom - rect.top, - }), - }); - for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect); - globalThis.document.getElementById = (id) => nodes.get(id) ?? null; - f.owner._initPanelDrag(); - return { - ...panels, - obstacle, - observed: (node) => observers.some(({ targets }) => targets.includes(node)), - contentResized: () => observers.forEach(({ callback }) => callback([])), - }; -} - -test('a floating window whose content grows is pulled back on-screen', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { 'cctv-panel': { left: 900, top: 380 } }, - }); - const { panel, natural } = shell['cctv-panel']; - assert.equal(panel.style.top, '380px'); - assert.equal(shell.observed(panel), true, 'its size is watched'); - assert.equal(shell.observed(shell['street-level-panel'].panel), true); - // Expanding the collapsed strip, or an image opening in Street Level. - natural.height = 800; - shell.contentResized(); - assert.equal(panel.style.top, `${900 - 800 - 6}px`); - assert.equal(panel.style.left, '900px'); - } finally { - f.restore(); - } -}); - -test('a live window resize shrinks a floating window larger than the viewport', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { - 'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 }, - }, - }); - const { panel } = shell['cctv-panel']; - assert.equal(panel.style.width, '1300px'); - Object.assign(window, { innerWidth: 1000, innerHeight: 500 }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.width, '988px'); - assert.equal(panel.style.height, '488px'); - assert.equal(panel.style.left, '6px'); - assert.equal(panel.style.top, '6px'); - // A window that already fits keeps the size the user gave it. - Object.assign(window, { innerWidth: 1440, innerHeight: 900 }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.width, '988px'); - } finally { - f.restore(); - } -}); - -const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 }; - -test('a floating header never lands under the command dock or voice pill', () => { - const f = fixture(); - try { - const shell = portableShell(f, { - stored: { 'cctv-panel': { left: 100, top: 80 } }, - // A collapsed CCTV window is just its header strip. - box: { 'cctv-panel': { height: 36 } }, - obstacles: { 'command-dock': DOCK }, - }); - const { panel, handle, natural } = shell['cctv-panel']; - const above = DOCK.top - 36 - 6; - - // Drag: the strip dropped at the bottom centre stops above the dock. - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 720, y: 880 }); - assert.equal(panel.style.top, `${above}px`); - pointer(window, 'pointerup', { x: 720, y: 880 }); - const key = 'godsEyeView.v8.panelPos.cctv-panel'; - assert.equal(JSON.parse(f.store.get(key)).top, above); - - // Restore: a place saved under the dock (older build) comes back clear. - f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true })); - f.owner._restorePanelPosition('cctv-panel', panel); - assert.equal(panel.style.top, `${above}px`); - - // Re-clamp: the voice pill measured when the window resizes. - Object.assign(panel.style, { left: '1000px', top: '700px' }); - shell.obstacle('gev-voice-control', { - left: 1190, - top: 690, - right: 1404, - bottom: 740, - }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.top, `${690 - 36 - 6}px`); - - // Only the header has to stay clear: a body over the dock is fine. - natural.height = 200; - Object.assign(panel.style, { left: '620px', top: '650px' }); - window.dispatchEvent(new Event('resize')); - assert.equal(panel.style.top, '650px'); - } finally { - f.restore(); - } -}); - -test('a collapsed floating window keeps its chosen height through a drag and a reload', () => { - const f = fixture(); - try { - const { panel, handle } = floatingPanel(f, 'cctv-panel'); - panel.style.height = '500px'; - panel.classList.add('collapsed'); - // Collapsed, the window measures only its header strip. - panel.getBoundingClientRect = () => { - const left = parseFloat(panel.style.left); - const top = parseFloat(panel.style.top); - return { - left, - top, - width: 360, - height: 50, - right: left + 360, - bottom: top + 50, - }; - }; - pointer(handle, 'pointerdown', { x: 200, y: 90 }); - pointer(window, 'pointermove', { x: 260, y: 140 }); - pointer(window, 'pointerup', { x: 260, y: 140 }); - const record = JSON.parse( - f.store.get('godsEyeView.v8.panelPos.cctv-panel'), - ); - assert.equal(record.height, 500, 'the chosen height, not the strip'); - assert.equal(record.width, 360); - - // Reload: a fresh panel restores the size it expands to. - const reloaded = f.element(); - reloaded.id = 'cctv-panel'; - f.owner._restorePanelPosition('cctv-panel', reloaded); - assert.equal(reloaded.style.height, '500px'); - } finally { - f.restore(); - } -}); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index c46a8324c..fc170646e 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -205,12 +205,9 @@ export class PanelLayoutController { this._streetLevelPanel, ]) { if (!panel) continue; - // A floating window keeps its restored place and size. - if (!panel.classList.contains('panel-floating')) { - for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) - panel.style.removeProperty(property); - panel.classList.remove('panel-draggable', 'panel-dragging'); - } + for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) + panel.style.removeProperty(property); + panel.classList.remove('panel-draggable', 'panel-dragging'); stack.insertBefore(panel, globalContextPanel); this._syncPanelCollapseButton(panel); } diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index b40a2d378..812122abe 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,6 +1,4 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ -import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; - /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -12,203 +10,40 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; -/** - * Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is - * 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css. - */ -const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11; -/** Pointer travel before a press becomes a drag or resize. */ -const DRAG_THRESHOLD_PX = 4; -/** - * Double press that snaps a floating panel back. Detected from pointerdown - * because the drag's preventDefault() suppresses a native dblclick. - */ -const DOUBLE_PRESS_MS = 400; -const DOUBLE_PRESS_SLOP_PX = 6; -const VIEWPORT_MARGIN_PX = 6; -/** - * Fixed chrome that paints over the rail's stacking context; a floating - * window keeps its header clear of it so it can always be grabbed again. - */ -const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control']; -const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; -const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; -/** Header children whose own interaction wins over a drag or a snap-back. */ -const INTERACTIVE_SELECTOR = - 'input, select, option, textarea, button, a, [role="button"]'; -const FLOATING_STYLE_PROPERTIES = [ - 'left', - 'top', - 'right', - 'bottom', - 'width', - 'height', - 'z-index', -]; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function fitWindowExtent(value, minimum, viewportExtent) { - return clamp( - Math.round(value), - minimum, - Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX), - ); -} - -function placeAt(panelEl, left, top) { - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.right = 'auto'; - panelEl.style.bottom = 'auto'; -} - -/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */ -function isPrecisePointer(event) { - const pointerType = event.pointerType || 'mouse'; - return pointerType === 'mouse' || pointerType === 'pen'; -} - -function geometryOf(panelEl) { - const { left, top, width, height } = panelEl.style; - return { left, top, width, height }; -} - export class PanelPositionControls { - /** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */ constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast, - onPanelResized = null, }) { this._syncPanelCollapseButton = syncPanelCollapseButton; this._layoutRightPanels = layoutRightPanels; this._syncCctvPanelViewport = syncCctvPanelViewport; this._showToast = showToast; - this._onPanelResized = onPanelResized; this._ppToggles = document.getElementById('pp-toggles'); this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; - this._portablePanels = new Map(); - this._resizeHandles = []; - this._dragInitialized = false; this.removers = []; this.destroyed = false; } - listen(target, type, callback, options) { + listen(target, type, callback) { if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; - target.addEventListener(type, listener, options); - this.removers.push(() => - target.removeEventListener(type, listener, options), - ); + target.addEventListener(type, listener); + this.removers.push(() => target.removeEventListener(type, listener)); } _reclampDraggablePanels() { if (this.destroyed) return; const el = this._ppToggles; - if (el && el.style.top && el.style.top !== 'auto') { - const top = parseInt(el.style.top, 10); - if (Number.isFinite(top)) { - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); - } - } - for (const entry of this._portablePanels.values()) { - const { classList } = entry.panel; - if (!classList.contains('panel-floating')) continue; - // A live gesture owns the box; it re-clamps when it ends. - if (classList.contains('panel-dragging')) continue; - if (classList.contains('panel-resizing')) continue; - this._reclampPortablePanel(entry); - } - } - - /** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */ - _reclampPortablePanel(entry) { - const { panel, min } = entry; - const left = parseInt(panel.style.left, 10); - const top = parseInt(panel.style.top, 10); - if (!Number.isFinite(left) || !Number.isFinite(top)) return; - for (const [property, viewportExtent] of [ - ['width', window.innerWidth], - ['height', window.innerHeight], - ]) { - const extent = parseFloat(panel.style[property]); - if (!Number.isFinite(extent)) continue; - const fitted = fitWindowExtent(extent, min[property], viewportExtent); - if (fitted < extent) panel.style[property] = `${fitted}px`; - } - const next = this._clampPortableWindow(entry, left, top); - panel.style.left = `${next.left}px`; - panel.style.top = `${next.top}px`; - } - - /** - * Clamp a floating window inside the viewport margin with its header above - * the command dock and voice pill, measured each time since the dock resizes. - */ - _clampPortableWindow( - entry, - left, - top, - geometry = this._windowGeometry(entry), - ) { - const { width, height, header } = geometry; - const maxLeft = Math.max( - VIEWPORT_MARGIN_PX, - window.innerWidth - width - VIEWPORT_MARGIN_PX, - ); - const maxTop = Math.max( - VIEWPORT_MARGIN_PX, - window.innerHeight - height - VIEWPORT_MARGIN_PX, - ); - const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft); - let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop); - const obstacles = this._panelObstacles(); - for (let pass = 0; pass < obstacles.length; pass++) { - const hit = obstacles.find( - (rect) => - rect.left < x + width && - rect.right > x && - rect.top < y + header && - rect.bottom > y, - ); - if (!hit) break; - const above = hit.top - header - VIEWPORT_MARGIN_PX; - y = - above >= VIEWPORT_MARGIN_PX - ? above - : Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX); - } - return { left: x, top: y }; - } - - _windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) { - const handleRect = handle?.getBoundingClientRect(); - const header = handleRect ? handleRect.bottom - rect.top : 0; - return { - width: rect.width, - height: rect.height, - // Without a measurable header, keep the whole window clear. - header: header > 0 ? Math.min(header, rect.height) : rect.height, - }; - } - - _panelObstacles() { - const rects = []; - for (const id of PANEL_OBSTACLE_IDS) { - const rect = document.getElementById(id)?.getBoundingClientRect(); - // A hidden dock, or a voice UI switched off, measures empty. - if (rect && rect.width > 0 && rect.height > 0) rects.push(rect); - } - return rects; + if (!el || !el.style.top || el.style.top === 'auto') return; + const top = parseInt(el.style.top, 10); + if (!Number.isFinite(top)) return; + el.style.top = `${this._clampToViewport(0, top, el).top}px`; + this._pinPanelToRight(el); } _maybeNotifyLayoutReset() { @@ -230,71 +65,28 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed || this._dragInitialized) return; - this._dragInitialized = true; - const cctvPanel = document.getElementById('cctv-panel'); - const streetLevelPanel = document.getElementById('street-level-panel'); - // A `portable` panel lifts out of its rail on a header drag, resizes from - // every edge and remembers its window; the others only reposition. + if (this.destroyed) return; const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, - { - id: 'cctv-panel', - panel: cctvPanel, - handle: cctvPanel?.querySelector('.panel-header'), - portable: true, - min: { width: 300, height: 160 }, - }, - { - id: 'street-level-panel', - panel: streetLevelPanel, - handle: streetLevelPanel?.querySelector('.panel-header'), - portable: true, - min: { width: 320, height: 280 }, - // Shrinking the window to its strip docks it, so it never sits - // over the globe as a stray header. - dockOnCollapse: true, - }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; - if (spec.portable) { - this._portablePanels.set(spec.id, { - panel: spec.panel, - handle: spec.handle, - min: { width: 300, height: 160, ...spec.min }, - dockOnCollapse: spec.dockOnCollapse === true, - }); - spec.panel.classList.add('panel-portable'); - } this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); - if (spec.portable) { - this._makePanelResizable(spec.id, spec.panel, spec.min); - } } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and - // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating - // windows grow the same way when their content does. - const observed = [ - this._ppToggles, - ...[...this._portablePanels.values()].map(({ panel }) => panel), - ].filter(Boolean); - if (observed.length && typeof ResizeObserver !== 'undefined') { + // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. + if (this._ppToggles && typeof ResizeObserver !== 'undefined') { this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels(), ); - for (const panel of observed) - this._draggableResizeObserver.observe(panel); - } - if (this._portablePanels.size) { - this.listen(window, 'resize', () => this._reclampDraggablePanels()); + this._draggableResizeObserver.observe(this._ppToggles); } } @@ -367,43 +159,17 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; - // Only portable panels store, and accept, floating records. - const portable = this._portablePanels.get(panelId); - if (Boolean(pos.floating) !== Boolean(portable)) return; - if (portable) { - panelEl.classList.add('panel-floating', 'panel-draggable'); - // A restored window must stack above the docked rail panels too. - this._promotePanelZ(panelEl); - if (Number.isFinite(pos.width)) { - panelEl.style.width = `${fitWindowExtent( - pos.width, - portable.min.width, - window.innerWidth, - )}px`; - } - if (Number.isFinite(pos.height)) { - panelEl.style.height = `${fitWindowExtent( - pos.height, - portable.min.height, - window.innerHeight, - )}px`; - } - } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at - // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too, - // and a window saved under the command dock must not come back there. - const { left, top } = portable - ? this._clampPortableWindow( - portable, - Math.round(pos.left), - Math.round(pos.top), - ) - : this._clampToViewport( - Math.round(pos.left), - Math.round(pos.top), - panelEl, - ); - placeAt(panelEl, left, top); + // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. + const { left, top } = this._clampToViewport( + Math.round(pos.left), + Math.round(pos.top), + panelEl, + ); + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; if (panelId === 'pp-toggles') { this._pinPanelToRight(panelEl); } @@ -424,28 +190,13 @@ export class PanelPositionControls { _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); - const record = { - left: Math.round(rect.left), - top: Math.round(rect.top), - }; - if (this._portablePanels.has(panelId)) { - // Only an explicitly set size is remembered, and the inline value - // rather than the measured box: a collapsed window measures only its - // header but keeps the size the user chose for when it expands. - const chosen = (inline, measured) => { - const px = parseFloat(inline); - return Math.round(Number.isFinite(px) ? px : measured); - }; - if (panelEl.style.width) - record.width = chosen(panelEl.style.width, rect.width); - if (panelEl.style.height) - record.height = chosen(panelEl.style.height, rect.height); - record.floating = true; - } try { localStorage.setItem( this._panelStorageKey(panelId), - JSON.stringify(record), + JSON.stringify({ + left: Math.round(rect.left), + top: Math.round(rect.top), + }), ); } catch { // storage unavailable @@ -458,7 +209,7 @@ export class PanelPositionControls { const promoted = [...document.querySelectorAll('.panel-draggable')] .filter((el) => el.style.zIndex) .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex)); - let z = PANEL_Z_FLOATING_FLOOR; + let z = PANEL_Z_BASE + 1; for (const el of promoted) { el.style.zIndex = String(z); z += 1; @@ -468,70 +219,11 @@ export class PanelPositionControls { panelEl.style.zIndex = String(this._panelZCounter); } - _maybeShowFloatHint() { - try { - if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; - localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); - } catch { - // storage unavailable: still show it once this session - if (this._floatHintShown) return; - this._floatHintShown = true; - } - this._showToast(PANEL_FLOAT_HINT); - } - - /** - * Turn a rail panel into a fixed window in place. Width is frozen so a - * `width: 100%` panel keeps its size; height stays natural until a resize. - */ - _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { - if (panelEl.classList.contains('panel-floating')) return false; - placeAt(panelEl, Math.round(rect.left), Math.round(rect.top)); - if (!panelEl.classList.contains('collapsed')) { - panelEl.style.width = `${Math.round(rect.width)}px`; - } - panelEl.style.removeProperty('--right-panel-allocated-height'); - panelEl.removeAttribute('aria-hidden'); - panelEl.classList.add('panel-floating', 'panel-draggable'); - this._promotePanelZ(panelEl); - this._layoutRightPanels(); - this._maybeShowFloatHint(); - return true; - } - - /** Snap a floating panel back into its rail and forget its window. */ - _resetPanelPosition(panelId) { - const portable = this._portablePanels.get(panelId); - if (!portable) return; - const panelEl = portable.panel; - this._cancelDrag?.(); - panelEl.classList.remove( - 'panel-floating', - 'panel-draggable', - 'panel-dragging', - 'panel-resizing', - ); - for (const property of FLOATING_STYLE_PROPERTIES) { - panelEl.style.removeProperty(property); - } - try { - localStorage.removeItem(this._panelStorageKey(panelId)); - } catch { - // storage unavailable - } - this._layoutRightPanels(); - this._onPanelResized?.(panelId); - } - _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context this.listen(panelEl, 'pointerdown', () => { this._promotePanelZ(panelEl); }); - if (this._portablePanels.has(panelId)) { - this._makePortablePanelDraggable(panelId, panelEl, handleEl); - return; - } this.listen(handleEl, 'pointerdown', (event) => { if (event.button !== 0) return; @@ -546,296 +238,64 @@ export class PanelPositionControls { this._cancelDrag?.(); event.preventDefault(); const rect = panelEl.getBoundingClientRect(); - const offsetX = event.clientX - rect.left; - const offsetY = event.clientY - rect.top; + const startX = event.clientX; + const startY = event.clientY; + const offsetX = startX - rect.left; + const offsetY = startY - rect.top; - placeAt(panelEl, rect.left, rect.top); + panelEl.style.left = `${rect.left}px`; + panelEl.style.top = `${rect.top}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; panelEl.classList.add('panel-dragging'); this._promotePanelZ(panelEl); - this._trackPointerGesture(event, { - onMove: (moveEvent) => { - const nextLeftRaw = moveEvent.clientX - offsetX; - const nextTopRaw = moveEvent.clientY - offsetY; - const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); - const maxTop = Math.max(6, window.innerHeight - rect.height - 6); - const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); - const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); - panelEl.style.left = `${nextLeft}px`; - panelEl.style.top = `${nextTop}px`; - if (panelId === 'pp-toggles') { - this._layoutRightPanels(); - } - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }, - onStop: () => panelEl.classList.remove('panel-dragging'), - // A cancelled press keeps the place too: there is no rail to revert to. - onEnd: () => { - if (panelId === 'pp-toggles') { - this._pinPanelToRight(panelEl); - } - this._savePanelPosition(panelId, panelEl); - if (panelId === 'cctv-panel') { - this._syncCctvPanelViewport(); - } - }, - }); - }); - } + const onMove = (moveEvent) => { + const nextLeftRaw = moveEvent.clientX - offsetX; + const nextTopRaw = moveEvent.clientY - offsetY; + const maxLeft = Math.max(6, window.innerWidth - rect.width - 6); + const maxTop = Math.max(6, window.innerHeight - rect.height - 6); + const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw)); + const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw)); + panelEl.style.left = `${nextLeft}px`; + panelEl.style.top = `${nextTop}px`; + if (panelId === 'pp-toggles') { + this._layoutRightPanels(); + } + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; - /** - * Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait - * until the pointer has travelled that far; `onEnd`/`onCancel` get whether - * it started. Stopping through `_cancelDrag` calls neither. - */ - _trackPointerGesture( - event, - { threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd }, - ) { - const startX = event.clientX; - const startY = event.clientY; - let started = threshold <= 0; - const move = (moveEvent) => { - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - if (!started) { - if (Math.hypot(dx, dy) < threshold) return; - started = true; - onStart?.(); - } - onMove(moveEvent, dx, dy); - }; - const stop = () => { - onStop?.(); - window.removeEventListener('pointermove', move); - window.removeEventListener('pointerup', up); - window.removeEventListener('pointercancel', cancel); - if (this._cancelDrag === stop) this._cancelDrag = null; - }; - const up = () => { - stop(); - onEnd?.(started); - }; - const cancel = () => { - stop(); - onCancel?.(started); - }; - this._cancelDrag = stop; - window.addEventListener('pointermove', move); - window.addEventListener('pointerup', up); - window.addEventListener('pointercancel', cancel); - } - - /** @returns {boolean} Whether the panel was floating and is now docked. */ - dockPanel(panelId) { - const portable = this._portablePanels.get(panelId); - if (!portable?.panel.classList.contains('panel-floating')) return false; - this._resetPanelPosition(panelId); - return true; - } - - /** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */ - onPanelCollapsed(panelId, collapsed) { - if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse) - return; - this.dockPanel(panelId); - } - - /** - * The collapse button still toggles on a plain click; the click that ends a - * real drag is swallowed so it cannot toggle by accident. - */ - _makePortablePanelDraggable(panelId, panelEl, handleEl) { - let swallowNextClick = false; - this.listen( - handleEl, - 'click', - (event) => { - if (!swallowNextClick) return; - swallowNextClick = false; - event.stopImmediatePropagation(); - event.preventDefault(); - }, - { capture: true }, - ); - this.listen(handleEl, 'dblclick', (event) => { - if (event.target.closest?.(INTERACTIVE_SELECTOR)) return; - if (!panelEl.classList.contains('panel-floating')) return; - event.preventDefault(); - this._resetPanelPosition(panelId); - }); - let lastPress = null; - this.listen(handleEl, 'pointerdown', (event) => { - if (event.button !== 0 || !isPrecisePointer(event)) return; - const interactive = event.target.closest?.(INTERACTIVE_SELECTOR); - if (interactive && !interactive.matches('.panel-collapse-btn')) return; - - const now = event.timeStamp || performance.now(); - const doublePress = - !interactive && - lastPress && - now - lastPress.time <= DOUBLE_PRESS_MS && - Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <= - DOUBLE_PRESS_SLOP_PX; - lastPress = doublePress - ? null - : { time: now, x: event.clientX, y: event.clientY }; - if (doublePress && panelEl.classList.contains('panel-floating')) { - event.preventDefault(); - this._resetPanelPosition(panelId); - return; - } - - this._cancelDrag?.(); - // A header press must not start a text selection across the page. - event.preventDefault(); - const entry = this._portablePanels.get(panelId); - let lifted = false; - let before = null; - let offsetX = 0; - let offsetY = 0; - let geometry = null; - - this._trackPointerGesture(event, { - threshold: DRAG_THRESHOLD_PX, - onStart: () => { - // A drag between two presses is not a double-click. - lastPress = null; - const rect = panelEl.getBoundingClientRect(); - offsetX = event.clientX - rect.left; - offsetY = event.clientY - rect.top; - geometry = this._windowGeometry(entry, rect); - before = geometryOf(panelEl); - lifted = this._liftPanelOut(panelId, panelEl, rect); - panelEl.classList.add('panel-dragging'); - this._promotePanelZ(panelEl); - }, - onMove: (moveEvent) => { - moveEvent.preventDefault(); - const next = this._clampPortableWindow( - entry, - moveEvent.clientX - offsetX, - moveEvent.clientY - offsetY, - geometry, - ); - panelEl.style.left = `${next.left}px`; - panelEl.style.top = `${next.top}px`; - }, - onStop: () => panelEl.classList.remove('panel-dragging'), - onEnd: (dragged) => { - if (!dragged) return; - swallowNextClick = true; - setTimeout(() => { - swallowNextClick = false; - }, 0); - this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }, - onCancel: (dragged) => { - if (dragged) - this._revertPanelGesture(panelId, panelEl, lifted, before); - }, - }); - }); - } - - /** On pointercancel, restore the panel as the gesture found it; save nothing. */ - _revertPanelGesture(panelId, panelEl, lifted, before) { - if (lifted) { - this._resetPanelPosition(panelId); - return; - } - Object.assign(panelEl.style, before); - this._onPanelResized?.(panelId); - } - - /** Resizing a docked panel lifts it out first, so handles work in both states. */ - _makePanelResizable(panelId, panelEl, min = {}) { - const limits = { width: 300, height: 160, ...min }; - for (const dir of RESIZE_DIRECTIONS) { - const handle = document.createElement('div'); - handle.className = - dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge'; - handle.dataset.dir = dir; - handle.setAttribute('aria-hidden', 'true'); - if (dir === 'se') { - handle.title = 'Drag to resize · double-click the header to snap back'; - } - panelEl.appendChild(handle); - this._resizeHandles.push(handle); - this.listen(handle, 'pointerdown', (event) => - this._startPanelResize(event, panelId, panelEl, dir, limits), - ); - } - } - - _startPanelResize(event, panelId, panelEl, dir, limits) { - if (event.button !== 0 || !isPrecisePointer(event)) return; - event.preventDefault(); - this._cancelDrag?.(); - // Set once the pointer has travelled: a plain click on an edge must not - // lift the panel out. - let startBox = null; - let lifted = false; - let before = null; - const apply = ({ left, top, width, height }) => { - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - panelEl.style.width = `${width}px`; - panelEl.style.height = `${height}px`; - }; - - this._trackPointerGesture(event, { - threshold: DRAG_THRESHOLD_PX, - onStart: () => { - const rect = panelEl.getBoundingClientRect(); - before = geometryOf(panelEl); - lifted = this._liftPanelOut(panelId, panelEl, rect); - startBox = { - left: Math.round(rect.left), - top: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), - }; - panelEl.classList.add('panel-resizing'); - this._promotePanelZ(panelEl); - apply(startBox); - }, - onMove: (moveEvent, dx, dy) => { - moveEvent.preventDefault(); - apply( - resizeBox(startBox, dir, dx, dy, { - minWidth: limits.width, - minHeight: limits.height, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - }), - ); - }, - onStop: () => panelEl.classList.remove('panel-resizing'), - onEnd: (resized) => { - if (!resized) return; - // Re-clamping skipped the live gesture; a north edge may have moved - // the header under the command dock. - const entry = this._portablePanels.get(panelId); - if (entry) this._reclampPortablePanel(entry); + const cancel = () => { + panelEl.classList.remove('panel-dragging'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (panelId === 'pp-toggles') { + this._pinPanelToRight(panelEl); + } this._savePanelPosition(panelId, panelEl); - this._onPanelResized?.(panelId); - }, - onCancel: (resized) => { - if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before); - }, + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); }); } - destroy() { if (this.destroyed) return; this.destroyed = true; this._cancelDrag?.(); for (const remove of this.removers.splice(0)) remove(); - for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..df97bf721 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -113,10 +113,7 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => - selector === '[data-panel-id]' || - (selector === '[data-panel-id]:not(.panel-floating)' && - !classes.has('panel-floating')), + matches: (selector) => selector === '[data-panel-id]', contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) @@ -325,28 +322,6 @@ test('right layout retains Display allocation during measurement and caps restor ); }); -test('right rail neither measures nor allocates a panel floating out of it', () => { - const f = fixture('right'); - f.expand(f.first, 900); - f.expand(f.second, 300); - f.first.classList.add('panel-floating'); - f.first.style.setProperty('--right-panel-allocated-height', '500px'); - f.first.writes.length = 0; - f.run(); - assert.equal(f.stack.dataset.expandedCount, '1'); - assert.deepEqual( - f.first.writes, - [], - 'a floating panel keeps whatever inline style it left the rail with', - ); - assert.equal(f.first.getAttribute('aria-hidden'), undefined); - assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); - assert.ok( - f.second.style.getPropertyValue('--right-panel-allocated-height'), - 'the docked panel still receives its allocation', - ); -}); - test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js deleted file mode 100644 index d3ac848ed..000000000 --- a/src/ui/panelResize.js +++ /dev/null @@ -1,47 +0,0 @@ -// Pure resize geometry for floating panels; PanelPositionControls wires the DOM. - -const EDGE_MARGIN_PX = 6; -export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -/** - * Edges named in `dir` follow the pointer (dx, dy since gesture start) and the - * opposite edge stays put. Minimum size and the viewport margin win. - */ -export function resizeBox( - box, - dir, - dx, - dy, - { - minWidth, - minHeight, - viewportWidth = Infinity, - viewportHeight = Infinity, - margin = EDGE_MARGIN_PX, - } = {}, -) { - let { left, top, width, height } = box; - const right = box.left + box.width; - const bottom = box.top + box.height; - if (dir.includes('e')) - width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); - if (dir.includes('s')) - height = clamp( - box.height + dy, - minHeight, - viewportHeight - margin - box.top, - ); - if (dir.includes('w')) { - width = clamp(box.width - dx, minWidth, right - margin); - left = right - width; - } - if (dir.includes('n')) { - height = clamp(box.height - dy, minHeight, bottom - margin); - top = bottom - height; - } - return { left, top, width, height }; -} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs deleted file mode 100644 index 3e1577665..000000000 --- a/src/ui/panelResize.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { resizeBox } from './panelResize.js'; - -const box = { left: 100, top: 100, width: 400, height: 300 }; -const limits = { - minWidth: 200, - minHeight: 150, - viewportWidth: 1000, - viewportHeight: 800, -}; - -test('east and south edges grow away from the anchored corner', () => { - assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { - left: 100, - top: 100, - width: 450, - height: 320, - }); - assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { - left: 100, - top: 100, - width: 370, - height: 300, - }); -}); - -test('west and north edges keep the opposite edge pinned', () => { - assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { - left: 60, - top: 100, - width: 440, - height: 300, - }); - assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { - left: 140, - top: 130, - width: 360, - height: 270, - }); - assert.deepEqual( - resizeBox(box, 'n', -50, 0, limits).top, - 100, - 'horizontal motion does not move a north edge', - ); -}); - -test('minimum size stops the moving edge, not the anchored one', () => { - const small = resizeBox(box, 'w', 350, 0, limits); - assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); - assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); - assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); -}); - -test('the viewport margin caps growth in every direction', () => { - assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); - assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); - const west = resizeBox(box, 'w', -2000, 0, limits); - assert.equal(west.left, 6); - assert.equal(west.left + west.width, 500); - const north = resizeBox(box, 'n', 0, -2000, limits); - assert.equal(north.top, 6); - assert.equal(north.top + north.height, 400); -}); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..70dc776dc 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,10 +69,8 @@ export function layoutRightPanelRail({ // A collapse may schedule one follow-up, which only measures and allocates. const isCollapseRetry = pendingCollapseRetries.delete(stack); - // A floating (lifted-out) panel is not laid out by the rail. const panels = [...stack.children].filter( - (panel) => - panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, + (panel) => panel.matches('[data-panel-id]') && !panel.hidden, ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 4da1570bf..4c07a84c3 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -134,7 +134,7 @@ export class StreetLevelControls { }); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); this.listen(el.viewerExpand, 'click', () => - this.setViewerExpanded(!this.isViewerExpanded(), { dock: true }), + this.setViewerExpanded(!this.isViewerExpanded()), ); for (const button of el.renderButtons) { this.listen(button, 'click', () => { @@ -276,10 +276,6 @@ export class StreetLevelControls { if (this.layer.getUIState) this.render(this.layer.getUIState()); } - onPanelResized() { - this._requestResize(); - } - setCollapsed(collapsed, options = {}) { this.actions.setPanelCollapsed?.(collapsed, options); if (!collapsed) this._requestResize(); @@ -293,11 +289,7 @@ export class StreetLevelControls { ); } - /** - * A user's shrink (`dock`) also returns a floating panel to its rail, so the - * viewer does not land in a window over the globe. - */ - setViewerExpanded(expanded, { dock = false } = {}) { + setViewerExpanded(expanded) { const wrap = this._elements.viewerWrap; if (!wrap) return; const on = expanded === true; @@ -347,7 +339,6 @@ export class StreetLevelControls { button.setAttribute('aria-pressed', String(on)); button.setAttribute('aria-label', on ? 'Shrink' : 'Expand'); } - if (!on && dock) this.actions.dockPanel?.(); this._requestResize(); } @@ -430,7 +421,7 @@ export class StreetLevelControls { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); - this.setViewerExpanded(false, { dock: true }); + this.setViewerExpanded(false); return; } // A radio off the roving tab stop (tabindex -1) is not a stop either. diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index bcf39cda2..66e65f042 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -407,7 +407,6 @@ async function productionPanel(dom) { setParams: (params, options) => manager.setLayerParams('street-level', params, options), setPanelCollapsed() {}, - dockPanel() {}, showToast() {}, }, }); @@ -546,7 +545,6 @@ function stubPanel(dom, state, extraActions = {}) { setParams: (params, options) => calls.setParams.push([params, options]), setPanelCollapsed: (collapsed, options) => calls.collapsed.push([collapsed, options]), - dockPanel() {}, showToast: (message) => calls.toasts.push(message), ...extraActions, }, diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 8607cd459..abc2012c2 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,115 +103,6 @@ opacity: 0.96; } -/* ── Portable rail panels ──────────────────── - Rail placement in layers.css is guarded with :not(.panel-floating), so - these rules only have to beat a panel's base placement (one id). */ -.panel-portable .panel-header { - cursor: grab; -} -.panel-portable.panel-dragging .panel-header { - cursor: grabbing; -} -#right-context-rail > .panel-floating { - position: fixed; - inset: auto; - /* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */ - z-index: 111; - max-height: calc(100vh - 12px); - margin: 0; - flex: none; - pointer-events: auto; -} -/* A collapsed window shows only its header strip; its inline height stays. */ -#right-context-rail > .panel-floating.collapsed { - max-height: max-content; -} -.panel-floating[style*='height'] > .cctv-panel-inner { - box-sizing: border-box; - height: 100%; - max-height: none; -} -.panel-resize-edge, -.panel-resize-grip { - position: absolute; - z-index: 3; - touch-action: none; -} -.panel-resize-edge[data-dir='n'], -.panel-resize-edge[data-dir='s'] { - left: 10px; - right: 10px; - height: 7px; - cursor: ns-resize; -} -.panel-resize-edge[data-dir='n'] { - top: -3px; -} -.panel-resize-edge[data-dir='s'] { - bottom: -3px; -} -.panel-resize-edge[data-dir='e'], -.panel-resize-edge[data-dir='w'] { - top: 10px; - bottom: 10px; - width: 7px; - cursor: ew-resize; -} -.panel-resize-edge[data-dir='e'] { - right: -3px; -} -.panel-resize-edge[data-dir='w'] { - left: -3px; -} -.panel-resize-edge[data-dir='ne'], -.panel-resize-edge[data-dir='nw'], -.panel-resize-edge[data-dir='sw'] { - width: 14px; - height: 14px; -} -.panel-resize-edge[data-dir='ne'] { - top: -3px; - right: -3px; - cursor: nesw-resize; -} -.panel-resize-edge[data-dir='nw'] { - top: -3px; - left: -3px; - cursor: nwse-resize; -} -.panel-resize-edge[data-dir='sw'] { - bottom: -3px; - left: -3px; - cursor: nesw-resize; -} -.panel-resize-grip { - right: 3px; - bottom: 3px; - width: 14px; - height: 14px; - cursor: nwse-resize; - opacity: 0.55; - clip-path: polygon(100% 0, 100% 100%, 0 100%); - background: repeating-linear-gradient( - 135deg, - transparent 0 3px, - rgba(0, 212, 255, 0.6) 3px 4px - ); - transition: opacity var(--transition-fast); -} -.panel-portable:hover .panel-resize-grip, -.panel-resizing .panel-resize-grip { - opacity: 1; -} -.panel-collapsible.collapsed > .panel-resize-edge, -.panel-collapsible.collapsed > .panel-resize-grip { - display: none; -} -.panel-resizing, -.panel-resizing * { - user-select: none; -} - .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 9b2534054..ba9bfbc68 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,8 +161,8 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel:not(.panel-floating), -#right-context-rail > #street-level-panel:not(.panel-floating), +#right-context-rail > #cctv-panel, +#right-context-rail > #street-level-panel, #right-context-rail > #weather-panel, #right-context-rail > #recent-imagery-panel, #right-context-rail > #global-context-panel { @@ -176,8 +176,8 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel:not(.panel-floating), -#right-context-rail #street-level-panel:not(.panel-floating), +#right-context-rail #cctv-panel, +#right-context-rail #street-level-panel, #right-context-rail #weather-panel, #right-context-rail #recent-imagery-panel { width: 100%; @@ -192,8 +192,8 @@ flex-shrink: 1; min-height: 0; } -#right-context-rail #cctv-panel.collapsed:not(.panel-floating), -#right-context-rail #street-level-panel.collapsed:not(.panel-floating), +#right-context-rail #cctv-panel.collapsed, +#right-context-rail #street-level-panel.collapsed, #right-context-rail #weather-panel.collapsed, #right-context-rail #recent-imagery-panel.collapsed { width: var(--right-display-collapsed-width); @@ -257,24 +257,19 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-exclusive > [data-panel-id].collapsed { display: none; } -#right-context-rail.layout-focus - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-focus > [data-panel-id].collapsed { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive - > [data-panel-id].collapsed:not(.panel-floating) { +#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { display: none; } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), -#right-context-rail.layout-focus - > #cctv-panel:not(.collapsed):not(.panel-floating), -#right-context-rail.layout-focus - > #street-level-panel:not(.collapsed):not(.panel-floating), +#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), +#right-context-rail.layout-focus > #street-level-panel:not(.collapsed), #right-context-rail.layout-focus > #weather-panel:not(.collapsed), #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { @@ -293,10 +288,10 @@ > #weather-panel:not(.collapsed) .weather-panel-inner, #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed):not(.panel-floating) + > #cctv-panel:not(.collapsed) .cctv-panel-inner, #right-context-rail.layout-focus - > #street-level-panel:not(.collapsed):not(.panel-floating) + > #street-level-panel:not(.collapsed) .street-level-panel-inner, #right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 4faccd150..8278b51a7 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -216,10 +216,10 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel:not(.panel-floating), - #right-context-rail #cctv-panel.collapsed:not(.panel-floating), - #right-context-rail #street-level-panel:not(.panel-floating), - #right-context-rail #street-level-panel.collapsed:not(.panel-floating), + #right-context-rail #cctv-panel, + #right-context-rail #cctv-panel.collapsed, + #right-context-rail #street-level-panel, + #right-context-rail #street-level-panel.collapsed, #right-context-rail #recent-imagery-panel, #right-context-rail #recent-imagery-panel.collapsed, #right-context-rail #radio-panel, diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 3991bc2ae..e2fd98eb8 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -125,7 +125,6 @@ button.sl-status:focus-visible { pointer-events: none; } -/* In a resized window only this block scrolls; the viewer keeps its room. */ .sl-settings { display: flex; flex-direction: column; @@ -476,52 +475,6 @@ button.sl-status:focus-visible { display: none; } -/* ── Floating window ─────────────────────────────────────────────────────── */ -/* A dragged window keeps its natural height, so bound it to the viewport: - the rail's max-height: 100% does not bind an auto-height parent. - Id-qualified to outrank that rule. */ -#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner { - max-height: calc(100vh - 12px); - overflow-y: auto; -} - -/* A resized window (inline height) gives its spare height to the viewer. */ -.panel-floating[style*='height'] > .street-level-panel-inner { - box-sizing: border-box; - height: 100%; - max-height: none; - overflow: hidden; -} - -.panel-floating[style*='height'] .sl-body, -.panel-floating[style*='height'] .sl-main { - flex: 1 1 auto; - min-height: 0; -} - -.panel-floating[style*='height'] .sl-viewer-wrap { - flex: 1 1 auto; - min-height: 0; -} - -.panel-floating[style*='height'] .sl-viewer-stage { - flex: 1 1 auto; - min-height: 140px; -} - -.panel-floating[style*='height'] .sl-viewer { - flex: 1 1 auto; - height: auto; - min-height: 0; - aspect-ratio: auto; -} - -.panel-floating[style*='height'] .sl-settings { - flex: 0 1 auto; - overflow-y: auto; - scrollbar-width: thin; -} - /* ── Expanded viewer ─────────────────────────────────────────────────────── */ /* Re-parented to : the panel's backdrop-filter would contain a fixed child. z-index sits above panels (≤139), below the voice pill (150). */ From 7dec7883aaa2093cc4d87963ab7e0f9689b0200a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 7 Oct 2026 21:50:11 +0400 Subject: [PATCH 023/131] =?UTF-8?q?refactor(street-level):=20simpler=20pan?= =?UTF-8?q?el=20=E2=80=94=20native=20fullscreen,=20no=20FOLLOW=20button,?= =?UTF-8?q?=20no=20provider=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPAND now uses the browser's Fullscreen API on the viewer element, which stays in the panel; the hand-built modal (re-parenting to , inert siblings, focus trap, document-level Esc/Tab, Clean View/recording/cockpit hide rules) is gone. The button is hidden where the API is missing. The FOLLOW button and the PROVIDERS chip row are removed. The header pill is the only layer switch and re-enables a provider switched off elsewhere; under KEY REQUIRED the error line says which key to add. Chip colour support in chipGroup, dead CSS and tests pinning the removed behaviour go with them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- README.md | 4 +- docs/CURRENT-STATE.md | 29 +- scripts/qa-street-level.mjs | 199 +------ src/streetLevelMarkup.test.mjs | 68 +-- src/tooling/streetLevelQa.test.mjs | 2 +- src/ui/chipGroup.js | 11 - src/ui/chipGroup.test.mjs | 21 - src/ui/streetLevelControls.js | 344 +++--------- src/ui/streetLevelControls.test.mjs | 709 +++++------------------- src/ui/streetLevelPresentation.js | 62 +-- src/ui/streetLevelPresentation.test.mjs | 135 +---- src/ui/styles/cockpit.css | 3 +- src/ui/styles/controls.css | 4 +- src/ui/styles/recording.css | 4 +- src/ui/styles/street-level.css | 118 +--- src/ui/templates/layer-panels.html | 10 +- 17 files changed, 285 insertions(+), 1444 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887a21566..37a6b3a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ - Street Level: a street-level imagery layer modelled on the iD editor's photo overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 - imagery). One right-rail panel holds a chip per provider, shared 360°/flat and - captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and - FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit + imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and + captured-since filters, a legend, and an embedded viewer with full-screen + EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit down to a single street; on Google 3D they sit on the bare earth, refined against the rendered mesh near the camera, so trees and buildings hide them. Share links carry the provider switches, the filters and the panel's collapsed diff --git a/README.md b/README.md index 22437b67f..90342371d 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | -| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | +| 📷 **Street Level** | Street-level photos behind one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | @@ -338,7 +338,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider draws in its own colour (Mapillary green; see the legend). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a1f7e1612..c691023b3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2938,15 +2938,16 @@ Street Level is a collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the layer on or a photo opens, never on a restore, and those opens are not stored. -Like the iD editor's photo overlay, it has one chip per registered provider, +Like the iD editor's photo overlay, it has one switch per registered provider +(share options, no panel chips), shared 360°/flat and captured-since filters (relative days, so a link keeps its meaning), one viewer host and one on-globe credit per active provider. Each provider draws in one colour (`PROVIDER_COLORS` in `src/layers/streetLevel/policy.js`); 360° cones are rings and the selected sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Only Mapillary is registered. Without -`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are -disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14 +`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, its error line says +which key to add, and the filters are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent requests for a tile. @@ -2968,16 +2969,16 @@ miss cooldown. Roads on elevated decks are still drawn at ground level. Framing a photo ignores mesh samples far below the bare earth (unloaded tiles). -The header pill is the layer switch. With one provider its chip is a layer -switch too; with several, darkening the last lit chip turns the layer off. The -viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the -image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and -stops when the stack changes. Camera moves go through the application's -navigation (`attachNavigation`), which releases aircraft and satellite tracking -and is refused in the cockpit: turning FOLLOW on claims the camera at once, and -FOLLOW stops when another feature takes it; a photo takes a deferred ticket -when it starts opening and frames only if nothing newer took the camera while -it loaded. SINCE is a stepped slider whose readout names the +The header pill is the only layer switch; switching on also turns back on a +provider a share link or tool switched off. The viewer sits under the header +with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's +Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK +leaves; the button is hidden where the API is missing). There is no FOLLOW +button; the layer's `setFollow` has no panel control. Camera moves go through +the application's navigation (`attachNavigation`), which releases aircraft and +satellite tracking and is refused in the cockpit; a photo takes a deferred +ticket when it starts opening and frames only if nothing newer took the camera +while it loaded. SINCE is a stepped slider whose readout names the cut-off date. At phone width (≤720 px) the viewer height is derived from the rail band so the whole photo fits. @@ -2988,7 +2989,7 @@ add a provider: implement the definition, register it in `src/app/layers/streetLevel.js`, add its boolean option to the `street-level` group in `src/data/layerState.js` and its modules to `scripts/package-boundaries.json`. Once a keyless provider registers, the -layer's `requiresKeyId` becomes null and the key gate moves to the chips. +layer's `requiresKeyId` becomes null and the key gate must move per provider. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 342a4aa2b..19fc83d2e 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -41,7 +41,7 @@ export const VIEWPORTS = Object.freeze([ { width: 1280, height: 800 }, ]); -/** Provider chips the panel must show, in order. */ +/** Providers the layer registers: one legend swatch each, plus "Selected". */ export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); /** @@ -448,15 +448,12 @@ async function assertKeylessGate(page) { const info = await readPanel(page); assert.equal(info.controlsDisabled, true); assert.equal(info.status, 'KEY REQUIRED'); - const chip = await page.$eval( - '#sl-provider-chips [data-chip-id="mapillary"]', - (node) => ({ - error: node.classList.contains('chip-error'), - title: node.title, - }), - ); - assert.equal(chip.error, true, 'the keyless provider chip reads as an error'); - assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); + const hint = await page.$eval('#sl-error', (node) => ({ + hidden: node.hidden, + text: node.textContent, + })); + assert.equal(hint.hidden, false, 'the panel says how to add the key'); + assert.match(hint.text, /MAPILLARY_CLIENT_TOKEN/); } /** @@ -655,32 +652,18 @@ async function main() { }; await expandStrip(); await step( - 'expanding the strip shows the body, one chip per provider and the legend', + 'expanding the strip shows the body and the legend', async () => { const info = await panel(); assert.ok(!isCollapsed(info.classes)); assert.equal(info.bodyDisplay, 'flex'); - const chips = await page.evaluate(() => - [ - ...document.querySelectorAll( - '#sl-provider-chips .data-toggle-chip', - ), - ].map((chip) => chip.dataset.chipId), - ); - assert.deepEqual(chips, EXPECTED_PROVIDERS); - // One swatch per source plus "Selected"; each chip wears its colour. + // One swatch per source plus "Selected". assert.equal( await page.evaluate( () => document.querySelectorAll('#sl-legend li').length, ), EXPECTED_PROVIDERS.length + 1, ); - assert.equal( - await page.$eval('[data-chip-id="mapillary"]', (chip) => - chip.style.getPropertyValue('--chip-color'), - ), - '#05cb63', - ); }, ); if (!status.configured) { @@ -779,7 +762,6 @@ async function main() { undefined, options, ); - const chip = '#sl-provider-chips [data-chip-id="mapillary"]'; const stacks = { active: () => page.evaluate(() => @@ -838,42 +820,6 @@ async function main() { ); }, ); - await step( - 'the only lit provider chip switches the whole layer off, credit and all', - async () => { - await clickPanelControl(page, chip); - await page.waitForFunction( - () => - !window.__godsEyeView.dataManager.isEnabled('street-level') && - window.__godsEyeView.dataManager.layers - .get('street-level') - .module.getUIState().coverage.count === 0, - { timeout: 15_000 }, - ); - await page.waitForFunction( - () => - !document.body.innerHTML.includes('Mapillary contributors'), - { timeout: 15_000 }, - ); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'false', - ); - // The provider stays switched on, so the layer comes back with it. - assert.equal((await ui()).providers[0].on, true); - }, - ); - await step( - 'lighting the chip turns the layer back on with coverage', - async () => { - await clickPanelControl(page, chip); - await waitForCoverage(page); - assert.equal( - await page.$eval(chip, (node) => node.getAttribute('aria-pressed')), - 'true', - ); - }, - ); await step( 'the 360° filter keeps at most the unfiltered sequence count', async () => { @@ -1026,14 +972,10 @@ async function main() { error: document.getElementById('sl-error').textContent, errorHidden: document.getElementById('sl-error').hidden, controls: document.getElementById('sl-controls').disabled, - chip: document - .querySelector('#sl-provider-chips [data-chip-id="mapillary"]') - .classList.contains('chip-error'), })); assert.match(shown.error, /rejected MAPILLARY_CLIENT_TOKEN/); assert.equal(shown.errorHidden, false); assert.equal(shown.controls, true); - assert.equal(shown.chip, true); // Panning asks for nothing more: the verdict holds for every tile. const rejectedAt = await tileRequestsQuiet(QUIET_MS); await panTo(-121.42, 38.63); @@ -1302,59 +1244,6 @@ async function main() { 'fixtures only', 'a click on a neighbouring cone steps the viewer to that photo', ); - await step( - 'FOLLOW is offered only on the Google 3D map', - async ({ skip }) => { - const follow = () => - page.$eval('#sl-follow-btn', (node) => ({ - disabled: node.disabled, - pressed: node.getAttribute('aria-pressed'), - title: node.title, - })); - const followIs = (disabled, pressed = null) => - settle( - page, - ([d, p]) => { - const node = document.getElementById('sl-follow-btn'); - return ( - node.disabled === d && - (p === null || node.getAttribute('aria-pressed') === p) - ); - }, - [disabled, pressed], - ); - const original = await stacks.active(); - await stacks.set('esri-imagery'); - await followIs(true); - let state = await follow(); - assert.equal(state.disabled, true, 'disabled on Esri'); - assert.match(state.title, /needs the Google 3D map/); - if (await stacks.photoreal()) { - await stacks.set('photoreal'); - await followIs(false); - state = await follow(); - assert.equal(state.disabled, false, 'enabled on Google 3D'); - await clickPanelControl(page, '#sl-follow-btn'); - await followIs(false, 'true'); - assert.equal((await follow()).pressed, 'true'); - await stacks.set('esri-imagery'); - await followIs(true, 'false'); - state = await follow(); - assert.equal( - state.pressed, - 'false', - 'leaving Google 3D stops following', - ); - assert.equal(state.disabled, true); - } else { - console.log( - ' (Google 3D unavailable here: only the disabled path ran)', - ); - skip('no Google 3D'); - } - await stacks.set(original); - }, - ); await park(); await step( 'closing a photo stops the framing flight toward it', @@ -1449,78 +1338,10 @@ async function main() { await stacks.set(original); }, ); - await step( - 'EXPAND opens a modal dialog (the rest of inert) and Esc returns focus to the button', - async () => { - await openById(firstImageId); - const inertBefore = await page.evaluate( - () => - [...document.body.children].filter((node) => node.inert).length, - ); - await clickPanelControl(page, '#sl-viewer-expand'); - await settle(page, () => - document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - const dialog = await page.evaluate(() => { - const wrap = document.getElementById('sl-viewer-wrap'); - const others = [...document.body.children].filter( - (node) => node !== wrap, - ); - return { - role: wrap.getAttribute('role'), - modal: wrap.getAttribute('aria-modal'), - inside: wrap.contains(document.activeElement), - width: Math.round(wrap.getBoundingClientRect().width), - onBody: wrap.parentElement === document.body, - notInert: others - .filter((node) => !node.inert) - .map((node) => node.id || node.tagName.toLowerCase()), - wrapInert: wrap.inert, - }; - }); - assert.equal(dialog.role, 'dialog'); - assert.equal(dialog.modal, 'true'); - assert.equal(dialog.inside, true); - assert.ok(dialog.width > 900); - assert.equal(dialog.onBody, true); - assert.deepEqual( - dialog.notInert, - [], - 'every other body child is inert', - ); - assert.equal(dialog.wrapInert, false); - await page.keyboard.press('Escape'); - await settle( - page, - () => - !document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - ); - const after = await page.evaluate(() => ({ - expanded: document - .getElementById('sl-viewer-wrap') - .classList.contains('sl-viewer-wrap-expanded'), - focus: document.activeElement?.id, - inert: [...document.body.children].filter((node) => node.inert) - .length, - })); - assert.equal( - `${after.expanded}:${after.focus}`, - 'false:sl-viewer-expand', - ); - assert.equal( - after.inert, - inertBefore, - 'the application is live again', - ); - }, - ); await step( '× closes the image and deselects it on the globe', async () => { + await openById(firstImageId); await clickPanelControl(page, '#sl-viewer-close'); await uiUntil( (u) => !u.street.open && u.sequence.selectedId === null, diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index e8df49460..03d2d1f9f 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -66,12 +66,7 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => { assert.equal((panelCss.match(/!important/g) || []).length, 0); - const fixed = panelCss - .replace(/\/\*[\s\S]*?\*\//g, '') - .split('}') - .filter((block) => /position:\s*fixed/.test(block)) - .map((block) => block.slice(0, block.indexOf('{')).trim()); - assert.deepEqual(fixed, ['.sl-viewer-wrap-expanded']); + assert.doesNotMatch(panelCss, /position:\s*fixed/); }); test('the keyless state gates the controls rather than leaving dead buttons', () => { @@ -81,13 +76,14 @@ test('the keyless state gates the controls rather than leaving dead buttons', () /sl-keyless|sl-query|sl-results|data-sl-suggestion/, ); assert.match(html, /
<\/div>/); - // The gate holds the filters only, so the chips' key tooltip stays reachable. + // The gate holds the filters; the error line above it says how to add the key. const gate = html.slice( html.indexOf('
', html.indexOf('
{ - assert.match(controls, /setAttribute\('role', 'dialog'\)/); - assert.match(controls, /setAttribute\('aria-modal', 'true'\)/); - assert.match(controls, /_expandReturnFocus/); - // A real modal: the rest of the application is inert while it is shown. - assert.match(controls, /node\.inert = true/); - assert.match(controls, /node\.inert = false/); -}); - test('labels say what the buttons do', () => { - for (const label of [ - 'aria-label="Camera follows view"', - 'PROVIDERS', - 'SINCE', - ]) + for (const label of ['>EXPAND<', '>FIT<', '>FILL<', 'SINCE']) assert.ok(html.includes(label), label); - // The provider chips are the on/off switch; no separate buttons. + // The header pill is the only on/off switch: no provider chips, no FOLLOW. assert.doesNotMatch( html, - /OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/, + /sl-provider-chips|sl-follow-btn|OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/, ); }); @@ -137,14 +120,14 @@ test('on phones the viewer is sized from the rail band, not its aspect ratio', ( panelCss, /@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/, ); - // The expanded viewer must not inherit the phone height. + // The full-screen viewer must not inherit the phone height. assert.match( panelCss, - /\.sl-viewer-wrap-expanded \.sl-viewer \{[^}]*height: auto/, + /\.sl-viewer-wrap:fullscreen \.sl-viewer \{[^}]*height: auto/, ); }); -test('the viewer comes first, with follow in its toolbar', () => { +test('the viewer comes first, with EXPAND in its toolbar', () => { const controlsBlock = html.slice(html.indexOf('
')); assert.ok( controlsBlock.indexOf('id="sl-viewer-wrap"') < @@ -153,33 +136,8 @@ test('the viewer comes first, with follow in its toolbar', () => { ); assert.match( controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')), - /id="sl-follow-btn"/, - 'follow lives in the viewer toolbar', - ); -}); - -test('Clean View, recording and the cockpit hide the expanded viewer on (P2-5)', () => { - // EXPAND moves the viewer out of every panel, past the panel-hiding rules. - assert.match( - read('src/ui/styles/controls.css'), - /body\.ui-clean-view \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/, - ); - assert.match( - read('src/ui/styles/recording.css'), - /body\.recording-mode \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/, - ); - assert.match( - read('src/ui/styles/cockpit.css'), - /body\.cockpit-mode\s*:is\([^)]*\.sl-viewer-wrap-expanded\s*\)\s*\{\s*display: none !important;/, - ); - // Hidden, it must not keep holding Esc and Tab. - assert.match(controls, /_isDialogShown\(\)/); -}); - -test('the expanded viewer keeps the panel tokens (P3)', () => { - assert.match( - panelCss, - /#street-level-panel,\s*\.sl-viewer-wrap \{\s*--sl-green:[^}]*--sl-text:[^}]*--sl-text-small:/, + /id="sl-viewer-expand"/, + 'EXPAND lives in the viewer toolbar', ); }); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 9f83b1c2b..48dbf5560 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -25,7 +25,7 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs'; globalThis.requestAnimationFrame ??= (callback) => setTimeout(() => callback(Date.now()), 0); -test('the harness expects exactly the providers the app registers, in chip order', () => { +test('the harness expects exactly the providers the app registers, in order', () => { // Any method a provider asks its source for is a no-op: only ids matter. const source = new Proxy({}, { get: () => () => {} }); const layer = createApplicationStreetLevel({ diff --git a/src/ui/chipGroup.js b/src/ui/chipGroup.js index 24eb44f3b..417312c66 100644 --- a/src/ui/chipGroup.js +++ b/src/ui/chipGroup.js @@ -28,17 +28,6 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) { `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`, ); set(button, 'textContent', chip.label); - // Optional per-chip colour; CSS decides how it is used. - const color = chip.color || ''; - if ((button.dataset.chipColor || '') !== color) { - if (color) { - button.style.setProperty('--chip-color', color); - button.dataset.chipColor = color; - } else { - button.style.removeProperty('--chip-color'); - delete button.dataset.chipColor; - } - } set(button, 'title', chip.title || ''); set(button, 'disabled', Boolean(chip.disabled)); attribute(button, 'aria-pressed', String(Boolean(chip.active))); diff --git a/src/ui/chipGroup.test.mjs b/src/ui/chipGroup.test.mjs index 784029a5c..36efa7d5f 100644 --- a/src/ui/chipGroup.test.mjs +++ b/src/ui/chipGroup.test.mjs @@ -31,24 +31,3 @@ test('keyed chips retain focus, reflect state, reorder and remove without redund syncChipGroup(f.container, []); assert.deepEqual(f.container.children, [legend]); }); - -test('a chip colour is set once, changed, and cleared through a CSS variable', () => { - const f = railFixture(() => {}); - const calls = []; - syncChipGroup(f.container, [{ id: 'm', label: 'M' }]); - const chip = f.container.children[0]; - chip.style.setProperty = (name, value) => calls.push(['set', name, value]); - chip.style.removeProperty = (name) => calls.push(['remove', name]); - syncChipGroup(f.container, [{ id: 'm', label: 'M' }]); - assert.deepEqual(calls, [], 'a chip without a colour never touches style'); - syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]); - syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]); - syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]); - syncChipGroup(f.container, [{ id: 'm', label: 'M' }]); - assert.deepEqual(calls, [ - ['set', '--chip-color', '#05cb63'], - ['set', '--chip-color', '#a66bff'], - ['remove', '--chip-color'], - ]); - assert.equal(chip.dataset.chipColor, undefined); -}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 4c07a84c3..1c0ec6c99 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -1,4 +1,3 @@ -import { syncChipGroup } from './chipGroup.js'; import { isExplicitLayerStateOrigin } from '../data/layerState.js'; import { presentStreetLevelPanel, @@ -6,8 +5,6 @@ import { } from './streetLevelPresentation.js'; const RENDER_MODE_KEY = 'gev:street-level:render-mode'; -const FOCUSABLE = - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; const RADIO_STEPS = Object.freeze({ ArrowRight: 1, ArrowDown: 1, @@ -25,8 +22,8 @@ const setAttr = (node, key, value) => { }; /** - * Fills the Street Level panel body: provider chips, filters, legend and - * viewer. The panel chrome itself belongs to the application shell. + * Fills the Street Level panel body: filters, legend and viewer. The panel + * chrome itself belongs to the application shell. */ export class StreetLevelControls { constructor({ root, layer, actions }) { @@ -37,9 +34,8 @@ export class StreetLevelControls { this.listeners = new AbortController(); this._unsubscribe = null; this._state = null; - this._view = null; this._wasEnabled = null; - this._wasStreetOpen = false; + this._wasOpen = false; // Until the user first touches the app, an off → on is the saved layer // state being restored, not a user switching the layer on. this._restoreWindow = true; @@ -48,11 +44,6 @@ export class StreetLevelControls { this._explicitEnable = null; this._unsubscribeEnableRequests = null; this._resizeQueued = false; - this._wrapHome = null; - this._expandReturnFocus = null; - // Only the children this class made inert are released again. - this._inerted = new Set(); - this._modalObserver = null; this._elements = this._collect(); this._bind(); } @@ -65,13 +56,11 @@ export class StreetLevelControls { return { status: byId('sl-status'), controls: byId('sl-controls'), - providerChips: byId('sl-provider-chips'), error: byId('sl-error'), errorText: byId('sl-error-text'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), legend: byId('sl-legend'), - followBtn: byId('sl-follow-btn'), viewerWrap: byId('sl-viewer-wrap'), viewerExpand: byId('sl-viewer-expand'), viewerClose: byId('sl-viewer-close'), @@ -82,7 +71,6 @@ export class StreetLevelControls { imageLink: byId('sl-image-link'), coverageMeta: byId('sl-coverage-meta'), panoButtons: all('[data-sl-pano]'), - // Held, not re-queried: EXPAND moves them to with the viewer. renderButtons: all('[data-sl-render]'), }; } @@ -100,17 +88,6 @@ export class StreetLevelControls { this.layer.attachViewerHost?.(el.viewer); this.listen(el.status, 'click', () => this._toggleEnabled()); - this.listen(el.providerChips, 'click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); - if (!button || button.disabled) return; - // Without a key the chips stay focusable for their tooltip; a click - // says the same thing rather than switching on a layer that cannot draw. - if (this._view?.controlsDisabled) { - if (button.title) this.actions.showToast?.(button.title); - return; - } - this._toggleProvider(button.dataset.chipId); - }); for (const button of el.panoButtons) { this.listen(button, 'click', () => this._setParams({ pano: button.dataset.slPano }), @@ -129,13 +106,13 @@ export class StreetLevelControls { this.listen(el.sinceRange, 'change', () => this._setParams({ sinceDays: sinceDays() }), ); - this.listen(el.followBtn, 'click', () => { - this.layer.setFollow?.(!(this._state?.street?.follow === true)); - }); this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.()); - this.listen(el.viewerExpand, 'click', () => - this.setViewerExpanded(!this.isViewerExpanded()), - ); + // EXPAND is the browser's own fullscreen: it handles Esc, focus and the + // rest of the page. Without the API the button is simply not offered. + if (typeof el.viewerWrap?.requestFullscreen === 'function') { + this.listen(el.viewerExpand, 'click', () => this._toggleFullscreen()); + this.listen(document, 'fullscreenchange', () => this._syncFullscreen()); + } else setProp(el.viewerExpand, 'hidden', true); for (const button of el.renderButtons) { this.listen(button, 'click', () => { const mode = button.dataset.slRender; @@ -154,13 +131,6 @@ export class StreetLevelControls { } catch { /* storage unavailable */ } - // The expanded viewer is a dialog: Esc shrinks it, Tab stays inside. - // Listen on the document, ahead of everything else, so both still hold - // once focus has left it (a click on the globe) and Esc never reaches - // the map's own handler, which would clear the selected sequence. - this.listen(document, 'keydown', (event) => this._onDialogKey(event), { - capture: true, - }); const endRestoreWindow = () => { this._restoreWindow = false; }; @@ -208,21 +178,17 @@ export class StreetLevelControls { }); } - async _ensureEnabled() { - if (this.actions.isEnabled?.()) return true; - try { - await this.actions.setEnabled?.(true); - } catch (error) { - this.actions.showToast?.( - error?.message || 'Street Level could not start', - ); - return false; - } - return this.actions.isEnabled?.() === true; - } - async _toggleEnabled() { const enabled = this.actions.isEnabled?.() === true; + // The pill is the only switch: switching on also brings back a provider + // a share link or a tool turned off, or the layer would draw nothing. + const dark = enabled + ? [] + : (this._state?.providers || []).filter((entry) => entry.on === false); + if (dark.length) + this._setParams( + Object.fromEntries(dark.map((entry) => [entry.id, true])), + ); try { await this.actions.setEnabled?.(!enabled); } catch (error) { @@ -237,32 +203,45 @@ export class StreetLevelControls { else this.layer.setParams?.(params); } - /** - * Lighting a chip turns the layer on; darkening the last lit one turns the - * layer off but keeps the provider on, so the layer comes back with it. - */ - async _toggleProvider(providerId) { - const chip = this._view?.providers.find((entry) => entry.id === providerId); - if (!chip) return; - if (!chip.active) { - this._setParams({ [providerId]: true }); - await this._ensureEnabled(); - return; - } - const othersLit = this._view.providers.some( - (entry) => entry.id !== providerId && entry.active, - ); - if (othersLit) { - this._setParams({ [providerId]: false }); - return; - } + isViewerFullscreen() { + const wrap = this._elements.viewerWrap; + return Boolean(wrap) && document.fullscreenElement === wrap; + } + + async _toggleFullscreen() { try { - await this.actions.setEnabled?.(false); - } catch (error) { - this.actions.showToast?.(error?.message || 'Street Level toggle failed'); + if (this.isViewerFullscreen()) await document.exitFullscreen(); + else await this._elements.viewerWrap.requestFullscreen(); + } catch { + /* refused (no user gesture, policy): the panel view stays */ } } + _exitFullscreen() { + if (this.isViewerFullscreen()) document.exitFullscreen().catch(() => {}); + } + + /** Mirror the browser's fullscreen state on EXPAND and refit the viewer. */ + _syncFullscreen() { + const on = this.isViewerFullscreen(); + const button = this._elements.viewerExpand; + if (button) { + setProp( + button.querySelector('.sl-btn-icon'), + 'textContent', + on ? '⤡' : '⤢', + ); + setProp( + button.querySelector('.sl-btn-text'), + 'textContent', + on ? 'SHRINK' : 'EXPAND', + ); + setAttr(button, 'aria-pressed', String(on)); + setAttr(button, 'aria-label', on ? 'Shrink' : 'Expand'); + } + this._requestResize(); + } + connect() { this._unsubscribe?.(); this._unsubscribe = null; @@ -281,183 +260,17 @@ export class StreetLevelControls { if (!collapsed) this._requestResize(); } - isViewerExpanded() { - return ( - this._elements.viewerWrap?.classList.contains( - 'sl-viewer-wrap-expanded', - ) === true - ); - } - - setViewerExpanded(expanded) { - const wrap = this._elements.viewerWrap; - if (!wrap) return; - const on = expanded === true; - if (on === this.isViewerExpanded()) return; - if (on) { - // Lift the viewer out of the panel: the panel's backdrop-filter would - // otherwise pin a fixed-position child inside it. - this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; - this._expandReturnFocus = document.activeElement; - document.body.appendChild(wrap); - wrap.classList.add('sl-viewer-wrap-expanded'); - wrap.setAttribute('role', 'dialog'); - wrap.setAttribute('aria-modal', 'true'); - wrap.setAttribute('aria-label', 'Street-level image'); - wrap.tabIndex = -1; - wrap.focus({ preventScroll: true }); - this._watchModal(true); - } else { - // Release the application first: the focus returned below may be in it. - this._watchModal(false); - wrap.classList.remove('sl-viewer-wrap-expanded'); - wrap.removeAttribute('role'); - wrap.removeAttribute('aria-modal'); - wrap.removeAttribute('aria-label'); - wrap.removeAttribute('tabindex'); - if (this._wrapHome?.parent) - this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next); - this._wrapHome = null; - const target = this._expandReturnFocus; - this._expandReturnFocus = null; - // A closed image hides the wrap: focus inside it would drop to . - const reachable = (node) => - node?.isConnected && - typeof node.focus === 'function' && - !(wrap.hidden && wrap.contains(node)); - if (reachable(target)) target.focus({ preventScroll: true }); - else if (reachable(this._elements.viewerExpand)) - this._elements.viewerExpand.focus({ preventScroll: true }); - else this._focusPanelControl(); - } - const button = this._elements.viewerExpand; - if (button) { - const icon = button.querySelector('.sl-btn-icon'); - const text = button.querySelector('.sl-btn-text'); - if (icon) icon.textContent = on ? '⤡' : '⤢'; - if (text) text.textContent = on ? 'SHRINK' : 'EXPAND'; - button.setAttribute('aria-pressed', String(on)); - button.setAttribute('aria-label', on ? 'Shrink' : 'Expand'); - } - this._requestResize(); - } - - /** Park focus on a control that stays on screen while the viewer is hidden. */ - _focusPanelControl() { - const el = this._elements; - const target = - el.status || el.providerChips?.querySelector('.data-toggle-chip'); - target?.focus?.({ preventScroll: true }); - } - - /** Clean View, recording and the cockpit hide the expanded viewer with CSS. */ - _isDialogShown() { - if (!this.isViewerExpanded()) return false; - const wrap = this._elements.viewerWrap; - return ( - typeof wrap.checkVisibility !== 'function' || - // `checkVisibilityCSS` is the option's name before Chrome 121. - wrap.checkVisibility({ - visibilityProperty: true, - checkVisibilityCSS: true, - }) - ); - } - - /** - * Make every other child inert while the expanded viewer is shown. - * A CSS-hidden viewer releases them so a hidden modal never leaves the app - * dead. Returns whether the dialog is shown. - */ - _syncModal() { - if (this.destroyed || !this._isDialogShown()) { - this._releaseModal(); - return false; - } - const wrap = this._elements.viewerWrap; - for (const node of [...(document.body?.children || [])]) { - if (node === wrap || node.inert) continue; - node.inert = true; - this._inerted.add(node); - } - return true; - } - - _releaseModal() { - for (const node of this._inerted) node.inert = false; - this._inerted.clear(); - } - - /** Re-check the modal when changes class or gains a child. */ - _watchModal(on) { - this._modalObserver?.disconnect(); - this._modalObserver = null; - if (!on) return this._releaseModal(); - if (typeof MutationObserver === 'function' && document.body) { - this._modalObserver = new MutationObserver(() => this._syncModal()); - this._modalObserver.observe(document.body, { - attributes: true, - attributeFilter: ['class'], - childList: true, - }); - } - this._syncModal(); - } - - _onDialogKey(event) { - if (event.key !== 'Escape' && event.key !== 'Tab') return; - if (!this._syncModal()) return; - // Only keys meant for the viewer: from inside it, from nowhere () - // or from the globe. A field elsewhere (search) keeps its Esc and Tab. - const target = event.target; - const wrap = this._elements.viewerWrap; - if ( - !wrap.contains(target) && - target !== document.body && - target !== document.documentElement && - !target?.closest?.('#cesiumContainer') - ) - return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - this.setViewerExpanded(false); - return; - } - // A radio off the roving tab stop (tabindex -1) is not a stop either. - const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter( - (node) => - node.getAttribute('tabindex') !== '-1' && - (node.offsetParent !== null || node === document.activeElement), - ); - if (!focusable.length) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } else if (!wrap.contains(document.activeElement)) { - event.preventDefault(); - first.focus(); - } - } - render(state) { if (this.destroyed || !state || !this.root) return; this._state = state; const view = presentStreetLevelPanel(state); - this._view = view; this._renderHeader(view); - this._renderGate(view); - this._renderProviders(view); + setProp(this._elements.controls, 'disabled', view.controlsDisabled); this._renderError(view); this._renderFilters(view); this._renderViewer(view); - this._renderMeta(view); - this._reactToTransitions(view, state); + setProp(this._elements.coverageMeta, 'textContent', view.meta); + this._reactToTransitions(view); } _renderHeader(view) { @@ -475,14 +288,6 @@ export class StreetLevelControls { } } - _renderGate(view) { - setProp(this._elements.controls, 'disabled', view.controlsDisabled); - } - - _renderProviders(view) { - syncChipGroup(this._elements.providerChips, view.providers); - } - _renderError(view) { const el = this._elements; if (!el.error) return; @@ -519,8 +324,7 @@ export class StreetLevelControls { setAttr(el.sinceRange, 'aria-valuetext', view.since.label); } } - // Rebuild when the swatches change, not only their count: one provider - // can replace another with the same number of entries. + // Rebuild when the swatches change, not only their count. const legendKey = view.legend .map((entry) => `${entry.key}:${entry.color}:${entry.label}`) .join('|'); @@ -545,25 +349,15 @@ export class StreetLevelControls { const el = this._elements; const { viewer } = view; const wrap = el.viewerWrap; + if (!viewer.open) this._exitFullscreen(); if (wrap) { // Closing the image (its × button) hides the wrap: move focus out - // first, or it drops to . A shrink below returns it itself. - if ( - !viewer.open && - !wrap.hidden && - !this.isViewerExpanded() && - wrap.contains(document.activeElement) - ) - this._focusPanelControl(); + // first, or it drops to . + if (!viewer.open && !wrap.hidden && wrap.contains(document.activeElement)) + el.status?.focus?.({ preventScroll: true }); setProp(wrap, 'hidden', !viewer.open); } - if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false); setProp(el.viewerPlaceholder, 'hidden', !viewer.loading); - if (el.followBtn) { - setAttr(el.followBtn, 'aria-pressed', String(viewer.follow.pressed)); - setProp(el.followBtn, 'disabled', viewer.follow.disabled); - setProp(el.followBtn, 'title', viewer.follow.title); - } this._renderRadios( el.renderButtons, (button) => button.dataset.slRender === viewer.renderMode, @@ -579,15 +373,11 @@ export class StreetLevelControls { } } - _renderMeta(view) { - setProp(this._elements.coverageMeta, 'textContent', view.meta); - } - /** * Open the panel on a switch-on or when an image opens. Never persisted: * the stored collapse state stays the user's own choice. */ - _reactToTransitions(view, state) { + _reactToTransitions(view) { const enabled = view.enabled; if (enabled && this._wasEnabled === false) { // A restore must not reopen a panel the user or a share link kept @@ -605,17 +395,15 @@ export class StreetLevelControls { this._wasEnabled = enabled; // Other size changes reach the viewer through the ResizeObserver; opening // asks for one resize because the element may not have a size yet. - const open = state.street.open === true; - if (open && !this._wasStreetOpen) - this.setCollapsed(false, { persist: false }); - this._wasStreetOpen = open; + const open = view.viewer.open; + if (open && !this._wasOpen) this.setCollapsed(false, { persist: false }); + this._wasOpen = open; } destroy() { if (this.destroyed) return; this.destroyed = true; - this.setViewerExpanded(false); - this._watchModal(false); + this._exitFullscreen(); this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 66e65f042..dd28ae794 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -16,20 +16,6 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs'; /** Every write a page would see as a mutation, even one to the same value. */ const mutations = { count: 0 }; const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value']; -/** The reflected properties a MutationObserver sees, as the record it gets. */ -const REFLECTED_RECORDS = { - hidden: { type: 'attributes', attributeName: 'hidden' }, - disabled: { type: 'attributes', attributeName: 'disabled' }, - title: { type: 'attributes', attributeName: 'title' }, - textContent: { type: 'childList' }, -}; - -const liveObservers = new Set(); - -function recordMutation(target, { type, attributeName = null }) { - for (const observer of liveObservers) - observer.consider(target, type, attributeName); -} class FakeNode { constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) { @@ -53,42 +39,20 @@ class FakeNode { set: (value) => { mutations.count++; this.props[key] = value; - if (REFLECTED_RECORDS[key]) - recordMutation(this, REFLECTED_RECORDS[key]); }, }); this.attributes = new Map(); const names = new Set(classes); - // Every DOMTokenList write sets the class attribute, changed or not. - const classChanged = () => - recordMutation(this, { type: 'attributes', attributeName: 'class' }); this.classList = { - add: (name) => { - names.add(name); - classChanged(); - }, - remove: (name) => { - names.delete(name); - classChanged(); - }, contains: (name) => names.has(name), toggle: (name, force) => { const on = force ?? !names.has(name); if (on) names.add(name); else names.delete(name); - classChanged(); return on; }, }; - const style = {}; - this.style = Object.assign(style, { - setProperty: (key, value) => { - style[key] = value; - }, - removeProperty: (key) => { - delete style[key]; - }, - }); + this.style = {}; Object.defineProperty(this, 'className', { get: () => [...names].join(' '), set: (value) => { @@ -96,92 +60,39 @@ class FakeNode { names.clear(); for (const name of String(value).split(/\s+/).filter(Boolean)) names.add(name); - classChanged(); }, }); } get childElementCount() { return this.children.length; } - get parentNode() { - return this.parent; - } - get nextSibling() { - if (!this.parent) return null; - const siblings = this.parent.children; - return siblings[siblings.indexOf(this) + 1] || null; - } - /** Null when this node or an ancestor is hidden or disconnected. */ - get offsetParent() { - if (!this.isConnected) return null; - for (let node = this; node; node = node.parent) - if (node.hidden) return null; - return this.parent; - } setAttribute(key, value) { mutations.count++; this.attributes.set(key, String(value)); - recordMutation(this, { type: 'attributes', attributeName: key }); } getAttribute(key) { return this.attributes.get(key) ?? null; } - removeAttribute(key) { - if (!this.attributes.delete(key)) return; - mutations.count++; - recordMutation(this, { type: 'attributes', attributeName: key }); - } appendChild(child) { - return this.insertBefore(child, null); - } - /** Like the DOM's: a reference that is not a child throws NotFoundError. */ - insertBefore(child, reference) { - if (reference != null && reference.parent !== this) - throw new DOMException('not a child of this node', 'NotFoundError'); - if (reference === child) return child; - child.remove(); - const index = reference - ? this.children.indexOf(reference) - : this.children.length; - this.children.splice(index, 0, child); child.parent = this; - recordMutation(this, { type: 'childList' }); + this.children.push(child); return child; } append(...nodes) { for (const node of nodes) this.appendChild(node); } replaceChildren(...nodes) { - for (const child of [...this.children]) child.remove(); + this.children = []; this.append(...nodes); } - remove() { - const parent = this.parent; - if (!parent) return; - parent.children.splice(parent.children.indexOf(this), 1); - this.parent = null; - recordMutation(parent, { type: 'childList' }); - } contains(node) { for (let current = node; current; current = current.parent) if (current === this) return true; return false; } - get isConnected() { - return this.ownerDocument?.contains(this) === true; - } focus() { this.ownerDocument.activeElement = this; } - /** `.class` or `#id`. */ - closest(selector) { - const byId = /^#(.+)$/.exec(selector); - const name = selector.replace(/^\./, ''); - for (let node = this; node; node = node.parent) - if (byId ? node.id === byId[1] : node.classList?.contains(name)) - return node; - return null; - } *walk() { for (const child of this.children) { yield child; @@ -191,79 +102,40 @@ class FakeNode { querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } - /** `#id`, `[data-x]`, and the tag / attribute forms of a focusable list. */ + /** `#id`, `.class` or `[data-x]`. */ matches(selector) { - return selector.split(',').some((part) => { - const one = part.trim(); - const byId = /^#(.+)$/.exec(one); - if (byId) return this.id === byId[1]; - const byData = /^\[data-([a-z-]+)\]$/.exec(one); - if (byData) - return ( - byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in - this.dataset - ); - if (one === '[tabindex]:not([tabindex="-1"])') - return ![null, '-1'].includes(this.getAttribute('tabindex')); - const byTag = /^([a-z]+)(\[href\])?(:not\(\[disabled\]\))?$/.exec(one); - if (!byTag || this.tagName !== byTag[1].toUpperCase()) return false; - if (byTag[2] && this.getAttribute('href') === null) return false; - return !(byTag[3] && this.disabled); - }); + const byId = /^#(.+)$/.exec(selector); + if (byId) return this.id === byId[1]; + const byClass = /^\.(.+)$/.exec(selector); + if (byClass) return this.classList.contains(byClass[1]); + const byData = /^\[data-([a-z-]+)\]$/.exec(selector); + return Boolean( + byData && + byData[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset, + ); } querySelectorAll(selector) { return [...this.walk()].filter((node) => node.matches(selector)); } - addEventListener(type, listener, { signal, capture = false } = {}) { + addEventListener(type, listener, { signal } = {}) { if (signal?.aborted) return; - if (!this.listeners.has(type)) this.listeners.set(type, new Map()); - this.listeners.get(type).set(listener, capture); + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type).add(listener); signal?.addEventListener('abort', () => this.listeners.get(type)?.delete(listener), ); } - removeEventListener(type, listener) { - this.listeners.get(type)?.delete(listener); - } - /** - * Capture down from the document, then deliver to this node and bubble - * back up with the same target; `stopPropagation` ends the trip. - */ + /** Deliver to this node, then bubble up with the same target. */ dispatchEvent(event) { - let stopped = false; const delivered = { type: event.type, target: this, - bubbles: event.bubbles, key: event.key, - shiftKey: event.shiftKey === true, - defaultPrevented: false, - preventDefault() { - this.defaultPrevented = true; - }, - stopPropagation() { - stopped = true; - }, + preventDefault() {}, }; - const path = []; - for (let node = this; node; node = node.parent) path.push(node); - const run = (node, phase) => { - for (const [listener, capture] of [ - ...(node.listeners.get(event.type) || []), - ]) - if (phase === 'target' || capture === (phase === 'capture')) - listener(delivered); - }; - for (const node of path.slice(1).reverse()) { - run(node, 'capture'); - if (stopped) return true; - } - run(this, 'target'); - if (!delivered.bubbles) return true; - for (const node of path.slice(1)) { - if (stopped) return true; - run(node, 'bubble'); - } + for (let node = this; node; node = event.bubbles ? node.parent : null) + for (const listener of [...(node.listeners.get(event.type) || [])]) + listener(delivered); return true; } click() { @@ -271,14 +143,24 @@ class FakeNode { } } -/** The Street Level panel's body, as layer-panels.html lays it out. */ -function panelDom() { +/** + * The Street Level panel's body, as layer-panels.html lays it out, on a page + * with the Fullscreen API unless `fullscreen` is false. + */ +function panelDom({ fullscreen = true } = {}) { const document = new FakeNode(null, '#document'); Object.assign(document, { ownerDocument: document, activeElement: null, + fullscreenElement: null, createElement: (tag) => new FakeNode(document, tag), }); + // The browser reports a change after the request settles. + const fullscreenTo = (element) => { + document.fullscreenElement = element; + setTimeout(() => document.dispatchEvent({ type: 'fullscreenchange' }), 0); + }; + document.exitFullscreen = async () => fullscreenTo(null); document.body = document.appendChild(new FakeNode(document, 'body')); const root = document.body.appendChild( new FakeNode(document, 'section', { id: 'street-level-panel' }), @@ -286,14 +168,14 @@ function panelDom() { const add = (parent, tag, options) => parent.appendChild(new FakeNode(document, tag, options)); add(root, 'button', { id: 'sl-status' }); - const main = add(root, 'div', { classes: ['sl-main'] }); - // The viewer's own tool bar lives inside the wrap, as in the markup. - const wrap = add(main, 'div', { id: 'sl-viewer-wrap' }); - add(wrap, 'button', { id: 'sl-viewer-expand' }); + const wrap = add(root, 'div', { id: 'sl-viewer-wrap' }); + if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap); + const expand = add(wrap, 'button', { id: 'sl-viewer-expand' }); + add(expand, 'span', { classes: ['sl-btn-icon'] }); + add(expand, 'span', { classes: ['sl-btn-text'] }); for (const mode of ['letterbox', 'fill']) add(wrap, 'button', { dataset: { slRender: mode } }); - for (const id of ['sl-follow-btn', 'sl-viewer-close']) - add(wrap, 'button', { id }); + add(wrap, 'button', { id: 'sl-viewer-close' }); for (const id of [ 'sl-viewer-placeholder', 'sl-viewer', @@ -302,35 +184,25 @@ function panelDom() { 'sl-image-link', ]) add(wrap, id === 'sl-image-link' ? 'a' : 'div', { id }); - const settings = add(main, 'div', { classes: ['sl-settings'] }); - for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text']) - add(settings, 'div', { id }); - // A missing key gates the filters only; the chips stay outside the gate. - const controls = add(settings, 'fieldset', { id: 'sl-controls' }); + const error = add(root, 'div', { id: 'sl-error' }); + add(error, 'span', { id: 'sl-error-text' }); + const controls = add(root, 'fieldset', { id: 'sl-controls' }); for (const pano of ['all', 'pano', 'flat']) add(controls, 'button', { dataset: { slPano: pano } }); add(controls, 'input', { id: 'sl-since' }); add(controls, 'output', { id: 'sl-since-label' }); - add(settings, 'ul', { id: 'sl-legend' }); - add(settings, 'div', { id: 'sl-coverage-meta' }); - // The globe's canvas is focusable too (tabindex=0). - const globe = add( - add(document.body, 'div', { id: 'cesiumContainer' }), - 'canvas', - ); - globe.setAttribute('tabindex', '0'); - // A field in another panel (the location search). - const search = add(document.body, 'input', { id: 'location-search' }); - return { document, root, globe, search, main, wrap, settings }; + add(root, 'ul', { id: 'sl-legend' }); + add(root, 'div', { id: 'sl-coverage-meta' }); + return { document, root, wrap }; } /** Run `fn` with the fake document and an immediate animation frame. */ -async function withDom(fn) { +async function withDom(fn, options) { const saved = { document: globalThis.document, requestAnimationFrame: globalThis.requestAnimationFrame, }; - const page = panelDom(); + const page = panelDom(options); globalThis.document = page.document; globalThis.requestAnimationFrame = (task) => setTimeout(task, 0); try { @@ -436,7 +308,7 @@ test('a 360° click reaches the share link through the data manager', () => 'durable state recorded it', ); assert.ok(share().includes(`${TOKEN}.p.p`), `share link: ${share()}`); - controls.destroy?.(); + controls.destroy(); coordinator.destroy(); })); @@ -451,11 +323,11 @@ test('releasing the SINCE slider records the window in the share link', () => coordinator.getDurableState().options['street-level'].sinceDays; assert.ok(days > 0, 'a window was recorded'); assert.ok(share().includes(`${TOKEN}.s.${days}`), `share link: ${share()}`); - controls.destroy?.(); + controls.destroy(); coordinator.destroy(); })); -/* ── Chip rules and render behaviour, against a stand-in layer ─────────── */ +/* ── Render behaviour, against a stand-in layer ────────────────────────── */ function stubLayer() { const listeners = new Set(); @@ -501,7 +373,7 @@ function uiState({ on, configured: !keyRequired, keyRequired, - requiresKeyId: keyRequired ? 'mapillary' : null, + requiresKeyId: 'mapillary', loading: false, count: 3, hint: '', @@ -517,8 +389,6 @@ function uiState({ street: { open, loading: false, - follow: false, - followAvailable: false, renderMode, providerId: open ? 'mapillary' : null, imageId: open ? 'img-1' : null, @@ -530,7 +400,7 @@ function uiState({ function stubPanel(dom, state, extraActions = {}) { const layer = stubLayer(); - const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] }; + const calls = { setParams: [], setEnabled: [], collapsed: [] }; let enabled = state.enabled; layer.publish(state); const controls = new StreetLevelControls({ @@ -545,60 +415,71 @@ function stubPanel(dom, state, extraActions = {}) { setParams: (params, options) => calls.setParams.push([params, options]), setPanelCollapsed: (collapsed, options) => calls.collapsed.push([collapsed, options]), - showToast: (message) => calls.toasts.push(message), + showToast() {}, ...extraActions, }, }); controls.connect(); - const chip = () => dom.root.querySelector('#sl-provider-chips').children[0]; - return { layer, calls, controls, chip }; + return { layer, calls, controls }; } -test('darkening the only lit chip turns the layer off and keeps the provider switched on', () => +test('the header pill switches the layer, bringing back a provider switched off elsewhere', () => withDom(async (dom) => { - const { calls, chip } = stubPanel(dom, uiState()); - assert.equal(chip().dataset.chipId, 'mapillary'); - chip().click(); + const status = dom.root.querySelector('#sl-status'); + const on = stubPanel(dom, uiState()); + status.click(); await settle(); - assert.deepEqual(calls.setEnabled, [false]); - assert.deepEqual(calls.setParams, [], 'the provider switch is untouched'); - })); - -test('lighting a dark chip switches the provider on as a user params request, then the layer', () => - withDom(async (dom) => { - const { calls, chip } = stubPanel( - dom, - uiState({ enabled: false, on: false }), + assert.deepEqual(on.calls.setEnabled, [false]); + assert.deepEqual( + on.calls.setParams, + [], + 'switching off keeps the provider', ); - chip().click(); + on.controls.destroy(); + const dark = stubPanel(dom, uiState({ enabled: false, on: false })); + status.click(); await settle(); - assert.deepEqual(calls.setParams, [ + assert.deepEqual(dark.calls.setParams, [ [{ mapillary: true }, { origin: 'user' }], ]); - assert.deepEqual(calls.setEnabled, [true]); + assert.deepEqual(dark.calls.setEnabled, [true]); + dark.controls.destroy(); + })); + +test('under KEY REQUIRED the filters are gated and the error line says how to add the key', () => + withDom(async (dom) => { + const { controls } = stubPanel(dom, uiState({ keyRequired: true })); + assert.equal(dom.root.querySelector('#sl-controls').disabled, true); + assert.equal(dom.root.querySelector('#sl-error').hidden, false); + assert.match( + dom.root.querySelector('#sl-error-text').textContent, + /^Mapillary: Needs MAPILLARY_CLIENT_TOKEN/, + ); + controls.destroy(); })); test('the legend rebuilds when a swatch changes, even at the same count', () => withDom(async (dom) => { - const { layer } = stubPanel(dom, uiState()); + const { layer, controls } = stubPanel(dom, uiState()); const legend = dom.root.querySelector('#sl-legend'); const swatch = () => legend.children[0].children[0].style.background; assert.equal(swatch(), '#05cb63'); layer.publish( uiState({ legend: [ - { key: 'panoramax', label: 'Panoramax', color: '#a66bff' }, + { key: 'mapillary', label: 'Mapillary', color: '#a66bff' }, { key: 'selected', label: 'Selected', color: '#00d4ff' }, ], }), ); assert.equal(legend.childElementCount, 2); assert.equal(swatch(), '#a66bff'); + controls.destroy(); })); test('the viewer is resized once when it opens, not on every render', () => withDom(async (dom) => { - const { layer } = stubPanel(dom, uiState()); + const { layer, controls } = stubPanel(dom, uiState()); await settle(); assert.equal(layer.calls.resize, 0); layer.publish(uiState({ open: true })); @@ -612,64 +493,6 @@ test('the viewer is resized once when it opens, not on every render', () => layer.publish(uiState({ open: true })); await settle(); assert.equal(layer.calls.resize, 2, 'the next photo resizes again'); - })); - -/* ── The expanded viewer as a dialog, and where focus goes ─────────────── */ - -const keydown = (target, key, { shiftKey = false } = {}) => - target.dispatchEvent({ type: 'keydown', bubbles: true, key, shiftKey }); - -/** The map's own Esc handler (selection.js) listens on the document. */ -function mapEscape(dom) { - const reached = []; - dom.document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') reached.push(event); - }); - return reached; -} - -test('Esc shrinks the expanded viewer after focus left it, and the map keeps its selection', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - const reached = mapEscape(dom); - controls.setViewerExpanded(true); - dom.globe.focus(); // a click on the globe - keydown(dom.globe, 'Escape'); - assert.equal(controls.isViewerExpanded(), false); - assert.equal(reached.length, 0, 'the selected sequence was not cleared'); - keydown(dom.globe, 'Escape'); - assert.equal(reached.length, 1, 'once shrunk, Esc is the map’s again'); - controls.destroy(); - })); - -test('Tab from outside the expanded viewer brings focus back into it', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - // A tool hidden in this state (no box, so not a tab stop) ends the bar. - const hiddenTool = dom.wrap.appendChild( - new FakeNode(dom.document, 'button', { id: 'sl-hidden-tool' }), - ); - hiddenTool.hidden = true; - controls.setViewerExpanded(true); - dom.globe.focus(); - keydown(dom.globe, 'Tab'); - const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); - assert.equal( - dom.document.activeElement, - wrap.querySelector('#sl-viewer-expand'), - ); - keydown(dom.document.activeElement, 'Tab', { shiftKey: true }); - assert.equal( - dom.document.activeElement, - wrap.querySelector('#sl-viewer-close'), - 'Shift+Tab wraps to the last control, past the hidden one', - ); - keydown(dom.document.activeElement, 'Tab'); - assert.equal( - dom.document.activeElement, - wrap.querySelector('#sl-viewer-expand'), - 'Tab from the last shown control wraps to the first', - ); controls.destroy(); })); @@ -678,7 +501,7 @@ test('closing the image from its × button leaves focus on the panel, not const { layer, controls } = stubPanel(dom, uiState({ open: true })); dom.root.querySelector('#sl-viewer-close').focus(); layer.publish(uiState({ open: false })); - assert.equal(dom.root.querySelector('#sl-viewer-wrap').hidden, true); + assert.equal(dom.wrap.hidden, true); assert.equal( dom.document.activeElement, dom.root.querySelector('#sl-status'), @@ -686,21 +509,61 @@ test('closing the image from its × button leaves focus on the panel, not controls.destroy(); })); -test('an image closed while expanded does not return focus into the hidden viewer', () => +/* ── EXPAND is the browser's fullscreen ────────────────────────────────── */ + +const expandLabel = (dom) => { + const button = dom.root.querySelector('#sl-viewer-expand'); + return [ + button.querySelector('.sl-btn-text').textContent, + button.getAttribute('aria-pressed'), + ]; +}; + +test('EXPAND puts the viewer full screen in place, and the button shrinks it again', () => withDom(async (dom) => { const { layer, controls } = stubPanel(dom, uiState({ open: true })); - dom.root.querySelector('#sl-viewer-expand').focus(); - controls.setViewerExpanded(true); // remembers EXPAND to return to - layer.publish(uiState({ open: false })); - assert.equal(controls.isViewerExpanded(), false); - assert.equal( - dom.document.activeElement, - dom.root.querySelector('#sl-status'), - ); + await settle(); + const resizes = layer.calls.resize; + const expand = dom.root.querySelector('#sl-viewer-expand'); + expand.click(); + await settle(); + await settle(); + assert.equal(dom.document.fullscreenElement, dom.wrap); + assert.equal(dom.root.contains(dom.wrap), true, 'never leaves the panel'); + assert.deepEqual(expandLabel(dom), ['SHRINK', 'true']); + assert.equal(layer.calls.resize, resizes + 1, 'the viewer refits'); + expand.click(); + await settle(); + await settle(); + assert.equal(dom.document.fullscreenElement, null); + assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']); controls.destroy(); })); -/* ── Restores, the modal viewer and keyboard rules ─────────────────────── */ +test('an image closed while full screen leaves full screen', () => + withDom(async (dom) => { + const { layer, controls } = stubPanel(dom, uiState({ open: true })); + dom.root.querySelector('#sl-viewer-expand').click(); + await settle(); + layer.publish(uiState({ open: false })); + await settle(); + assert.equal(dom.document.fullscreenElement, null); + assert.equal(dom.wrap.hidden, true); + assert.deepEqual(expandLabel(dom), ['EXPAND', 'false']); + controls.destroy(); + })); + +test('without the Fullscreen API there is no EXPAND button', () => + withDom( + async (dom) => { + const { controls } = stubPanel(dom, uiState({ open: true })); + assert.equal(dom.root.querySelector('#sl-viewer-expand').hidden, true); + controls.destroy(); + }, + { fullscreen: false }, + )); + +/* ── Restores and keyboard rules ───────────────────────────────────────── */ test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () => withDom(async (dom) => { @@ -763,205 +626,11 @@ test('with request origins, only a user switch-on opens the panel; a restore nev assert.equal(announce, null, 'destroy unsubscribes'); })); -test('Esc and Tab from a field outside the expanded viewer stay with that field (P3)', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - const reached = mapEscape(dom); - controls.setViewerExpanded(true); - dom.search.focus(); - keydown(dom.search, 'Escape'); - assert.equal(controls.isViewerExpanded(), true, 'the search keeps its Esc'); - assert.equal(reached.length, 1, 'and the event was not swallowed'); - keydown(dom.search, 'Tab'); - assert.equal(dom.document.activeElement, dom.search, 'Tab stays put'); - // From (focus nowhere), Esc is still the viewer's. - keydown(dom.document.body, 'Escape'); - assert.equal(controls.isViewerExpanded(), false); - controls.destroy(); - })); - -test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Esc nor Tab (P2-5)', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - const reached = mapEscape(dom); - controls.setViewerExpanded(true); - const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); - wrap.checkVisibility = () => false; // the mode's CSS hides it - dom.globe.focus(); - keydown(dom.globe, 'Tab'); - assert.equal(dom.document.activeElement, dom.globe); - keydown(dom.globe, 'Escape'); - assert.equal(controls.isViewerExpanded(), true); - assert.equal(reached.length, 1, 'Esc went on to the map'); - controls.destroy(); - })); - -/** - * A MutationObserver stand-in: queues only the records its options cover and - * delivers them in a microtask; `fire()` delivers at once. - */ -function fakeMutationObserver() { - const observers = []; - class FakeMutationObserver { - constructor(callback) { - this.callback = callback; - this.target = null; - this.options = null; - this.records = []; - observers.push(this); - } - observe(target, options = {}) { - this.target = target; - this.options = { - ...options, - // Per the spec, an attribute filter implies `attributes`. - attributes: options.attributes ?? Boolean(options.attributeFilter), - }; - liveObservers.add(this); - } - disconnect() { - this.target = null; - this.records = []; - liveObservers.delete(this); - } - consider(node, type, attributeName) { - const { childList, attributes, attributeFilter, subtree } = this.options; - const inScope = - node === this.target || - (subtree === true && this.target.contains(node)); - if (!inScope) return; - if (type === 'childList' && childList !== true) return; - if (type === 'attributes') { - if (attributes !== true) return; - if (attributeFilter && !attributeFilter.includes(attributeName)) return; - } - this.records.push({ type, target: node, attributeName }); - if (this.records.length === 1) queueMicrotask(() => this.deliver()); - } - deliver() { - if (!this.target || !this.records.length) return; - const records = this.records; - this.records = []; - this.callback(records, this); - } - } - const fire = () => { - for (const observer of observers) observer.deliver(); - }; - const watching = () => observers.some((observer) => observer.target); - return { FakeMutationObserver, fire, watching }; -} - -/** Run `fn` with a fake MutationObserver installed. */ -async function withObserver(fn) { - const saved = globalThis.MutationObserver; - const fake = fakeMutationObserver(); - globalThis.MutationObserver = fake.FakeMutationObserver; - try { - return await fn(fake); - } finally { - liveObservers.clear(); - if (saved === undefined) delete globalThis.MutationObserver; - else globalThis.MutationObserver = saved; - } -} - -/** Every child but the viewer, by id. */ -const inertOutside = (dom) => - dom.document.body.children - .filter((node) => node.id !== 'sl-viewer-wrap') - .map((node) => [node.id, node.inert === true]); - -test('the expanded viewer makes the rest of the app inert and gives it back on shrink', () => - withObserver(() => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - // Another dialog's own inert backdrop: it is not ours to release. - const owned = dom.document.body.appendChild( - new dom.globe.constructor(dom.document, 'div', { id: 'owned' }), - ); - owned.inert = true; - controls.setViewerExpanded(true); - assert.deepEqual(inertOutside(dom), [ - ['street-level-panel', true], - ['cesiumContainer', true], - ['location-search', true], - ['owned', true], - ]); - const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); - assert.notEqual(wrap.inert, true, 'the viewer itself stays live'); - // Shrinking returns focus into the panel: it must be live again first. - dom.root.querySelector('#sl-status').focus(); - controls.setViewerExpanded(false); - assert.deepEqual(inertOutside(dom), [ - ['street-level-panel', false], - ['cesiumContainer', false], - ['location-search', false], - ['owned', true], - ]); - controls.destroy(); - }), - )); - -test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert', () => - withObserver((observer) => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); - assert.equal(observer.watching(), true); - const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); - let visible = true; - wrap.checkVisibility = () => visible; - // Clean View (or recording, or the cockpit) hides the viewer by CSS. - visible = false; - dom.document.body.classList.add('ui-clean-view'); - observer.fire(); - assert.ok( - inertOutside(dom).every(([, inert]) => !inert), - 'a hidden modal leaves nothing inert', - ); - // Leaving the mode shows the viewer again: the app is inert once more, - // and so is a child added to meanwhile. - visible = true; - const toast = dom.document.body.appendChild( - new dom.globe.constructor(dom.document, 'div', { id: 'toast' }), - ); - observer.fire(); - assert.ok(inertOutside(dom).every(([, inert]) => inert)); - assert.equal(toast.inert, true); - controls.destroy(); - assert.ok( - inertOutside(dom).every(([, inert]) => !inert), - 'destroy releases the app', - ); - assert.equal(observer.watching(), false, 'and stops watching'); - }), - )); - -test('an image closed while expanded releases the app', () => - withObserver(() => - withDom(async (dom) => { - const { layer, controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); - assert.ok(inertOutside(dom).every(([, inert]) => inert)); - layer.publish(uiState({ open: false })); - assert.equal(controls.isViewerExpanded(), false); - assert.ok(inertOutside(dom).every(([, inert]) => !inert)); - assert.equal( - dom.document.activeElement, - dom.root.querySelector('#sl-status'), - ); - controls.destroy(); - }), - )); - -test('FIT / FILL show the selection while the viewer is expanded (P3)', () => +test('FIT / FILL show the selected render mode (P3)', () => withDom(async (dom) => { const { layer, controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); layer.publish(uiState({ open: true, renderMode: 'fill' })); - const wrap = dom.document.body.querySelector('#sl-viewer-wrap'); - const [fit, fill] = wrap.querySelectorAll('[data-sl-render]'); + const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]'); assert.equal(fill.getAttribute('aria-checked'), 'true'); assert.equal(fit.getAttribute('aria-checked'), 'false'); assert.equal(fill.classList.contains('is-active'), true); @@ -1011,6 +680,8 @@ test('identical renders write nothing to the DOM (P3: rail MutationObserver)', ( test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () => withDom(async (dom) => { const { layer, calls, controls } = stubPanel(dom, uiState()); + const keydown = (target, key) => + target.dispatchEvent({ type: 'keydown', bubbles: true, key }); const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]'); assert.deepEqual( [all, pano, flat].map((button) => button.getAttribute('tabindex')), @@ -1034,103 +705,3 @@ test('arrow keys move the selection within a radiogroup, which has one tab stop ); controls.destroy(); })); - -test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () => - withDom(async (dom) => { - const { calls, chip, controls } = stubPanel( - dom, - uiState({ keyRequired: true }), - ); - assert.equal(dom.root.querySelector('#sl-controls').disabled, true); - assert.equal( - dom.root.querySelector('#sl-controls').contains(chip()), - false, - 'the chip is outside the disabled fieldset', - ); - chip().click(); - await settle(); - assert.deepEqual(calls.setEnabled, []); - assert.deepEqual(calls.setParams, []); - assert.equal(calls.toasts.length, 1); - assert.match(calls.toasts[0], /^Mapillary: Needs /); - controls.destroy(); - })); - -/* ── The expanded viewer goes back where it came from ──────────────────── */ - -/** A node by name, so a failed comparison prints a word, not the DOM. */ -const nameOf = (node) => - node ? node.id || node.className || node.tagName.toLowerCase() : null; - -/** Where the viewer sits: its parent and the sibling it sits before. */ -const placeOf = (wrap) => [nameOf(wrap.parentNode), nameOf(wrap.nextSibling)]; -const PANEL_PLACE = ['sl-main', 'sl-settings']; - -test('shrinking returns the viewer into the panel at its own place', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE); - controls.setViewerExpanded(true); - assert.equal(nameOf(dom.wrap.parentNode), 'body', 'lifted out'); - controls.setViewerExpanded(false); - assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE, 'before the settings'); - assert.equal(dom.main.children.indexOf(dom.wrap), 0); - assert.equal(dom.root.contains(dom.wrap), true); - // A second round trip lands in the same place. - controls.setViewerExpanded(true); - controls.setViewerExpanded(false); - assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE); - controls.destroy(); - })); - -test('an image closed while expanded returns the viewer into the panel', () => - withDom(async (dom) => { - const { layer, controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); - layer.publish(uiState({ open: false })); - assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE); - assert.equal(dom.wrap.hidden, true, 'and hidden there'); - controls.destroy(); - })); - -test('destroying the panel while expanded returns the viewer into it', () => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); - controls.destroy(); - assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE); - assert.equal( - dom.document.body.children.includes(dom.wrap), - false, - 'nothing left on ', - ); - })); - -test('a child added while the viewer is expanded goes inert with the rest', () => - withObserver((observer) => - withDom(async (dom) => { - const { controls } = stubPanel(dom, uiState({ open: true })); - controls.setViewerExpanded(true); - // A toast, a menu or another panel's popup, appended later. - const toast = dom.document.body.appendChild( - new FakeNode(dom.document, 'div', { id: 'toast' }), - ); - assert.equal(toast.inert, undefined, 'not before the observer runs'); - await settle(); // records are delivered in a microtask - assert.equal(toast.inert, true); - // Changes inside the app are not the modal's business: nothing runs. - let syncs = 0; - const sync = controls._syncModal.bind(controls); - controls._syncModal = () => { - syncs++; - return sync(); - }; - dom.root.querySelector('#sl-status').setAttribute('class', 'x'); - dom.root.appendChild(new FakeNode(dom.document, 'div')); - await settle(); - assert.equal(syncs, 0); - controls.destroy(); - assert.equal(toast.inert, false, 'destroy releases it too'); - assert.equal(observer.watching(), false); - }), - )); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index af6ad8c8c..41bce731d 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -67,35 +67,13 @@ function presentStatus(state) { : { text: 'OFF', tone: '', pressed, title }; } -/** A chip is lit only while the layer and its provider are both on. */ -function presentProviders(state) { - const enabled = state.enabled === true; - return (state.providers || []).map((provider) => { - const keyRequired = provider.keyRequired === true; - const on = enabled && provider.on === true; - let title = `${provider.name} imagery ${on ? 'on' : 'off'}`; - if (provider.keyRejected && provider.error) - title = `${provider.name}: ${provider.error}`; - else if (keyRequired && provider.requiresKeyId) - title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`; - else if (provider.error) title = `${provider.name}: ${provider.error}`; - return { - id: provider.id, - label: provider.label, - color: provider.color || null, - title, - active: on, - disabled: false, - state: keyRequired - ? 'error' - : on && provider.loading - ? 'loading' - : on - ? 'active' - : 'idle', - busy: on && provider.loading === true, - }; - }); +/** How to add the missing key, for the provider that needs one. */ +function keyHint(state) { + const provider = (state.providers || []).find( + (entry) => entry.keyRequired === true && entry.requiresKeyId, + ); + const requirement = provider && keySetupRequirement(provider.requiresKeyId); + return requirement ? `${provider.name}: ${requirement}` : null; } function presentViewer(state) { @@ -113,19 +91,11 @@ function presentViewer(state) { captionRight: right.join(' · '), link: street.externalUrl || null, linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '', - follow: { - pressed: street.follow === true, - disabled: street.open !== true || street.followAvailable !== true, - title: - street.followAvailable === true - ? 'Camera follows view: move the globe camera wherever the street-level view looks' - : 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE', - }, }; } function presentMeta(state) { - if (!state.enabled) return 'Switch a provider on to draw its coverage.'; + if (!state.enabled) return 'Switch Street Level on to draw its coverage.'; if (state.sequence.selectedId) return state.sequence.loading ? 'Loading this sequence…' @@ -137,26 +107,20 @@ function presentMeta(state) { /** @param {{now?: number}} [options] Clock for the SINCE readout. */ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { - const enabled = state.enabled === true; - const keyRequired = state.keyRequired === true; const filter = state.filter || { pano: 'all', sinceDays: 0 }; - // A missing key is already shown by the status and chip tooltip; only a - // rejected key keeps its error line, since that message names the fix. - const keyMissing = keyRequired && state.keyRejected !== true; + // A missing key says how to add it; a rejected key's own error names the fix. + const keyMissing = state.keyRequired === true && state.keyRejected !== true; return { - enabled, - keyRequired, + enabled: state.enabled === true, status: presentStatus(state), - controlsDisabled: keyRequired, - providers: presentProviders(state), + controlsDisabled: state.keyRequired === true, error: keyMissing - ? null + ? keyHint(state) : state.street.error || state.coverage.error || null, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, since: presentSince(filter.sinceDays, now), legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), - wantsOpen: state.street.open === true, }; } diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index d5075ac69..85b6fae40 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -23,7 +23,6 @@ function snapshot(overrides = {}) { sequence: { providerId: null, selectedId: null, images: 0, loading: false }, street: { open: false, - follow: false, loading: false, error: null, providerId: null, @@ -65,7 +64,7 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => { assert.equal(view.status.tone, 'warn'); }); -test('KEY REQUIRED shows no raw error code under the pill', () => { +test('KEY REQUIRED says how to add the key instead of a raw error code', () => { const view = presentStreetLevelPanel( snapshot({ enabled: true, @@ -75,9 +74,7 @@ test('KEY REQUIRED shows no raw error code under the pill', () => { }), ); assert.equal(view.status.text, 'KEY REQUIRED'); - assert.equal(view.error, null); - // The chip still says how to add the key. - assert.match(view.providers[0].title, /^Mapillary: Needs /); + assert.match(view.error, /^Mapillary: Needs MAPILLARY_CLIENT_TOKEN/); }); test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => { @@ -96,8 +93,6 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => { assert.equal(view.status.tone, 'warn'); assert.equal(view.controlsDisabled, true); assert.equal(view.error, error); - assert.equal(view.providers[0].state, 'error'); - assert.equal(view.providers[0].title, `Mapillary: ${error}`); }); test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => { @@ -147,61 +142,6 @@ test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => ); }); -test('one chip per provider: on, off, loading, and keyless as an error chip', () => { - const view = presentStreetLevelPanel( - snapshot({ - enabled: true, - providers: [ - provider(), - provider({ - id: 'panoramax', - name: 'Panoramax', - label: 'PANORAMAX', - on: false, - requiresKeyId: null, - }), - provider({ - id: 'kartaview', - name: 'KartaView', - label: 'KARTAVIEW', - loading: true, - requiresKeyId: null, - }), - provider({ - id: 'google-street-view', - name: 'Google Street View', - label: 'STREET VIEW', - keyRequired: true, - requiresKeyId: 'google-maps', - }), - ], - }), - ); - assert.deepEqual( - view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]), - [ - ['mapillary', true, 'active', false], - ['panoramax', false, 'idle', false], - ['kartaview', true, 'loading', true], - ['google-street-view', true, 'error', false], - ], - ); - assert.equal(view.providers[0].label, 'MAPILLARY'); - assert.equal(view.providers[1].title, 'Panoramax imagery off'); - assert.match( - view.providers[3].title, - /^Google Street View: Needs GOOGLE_MAPS_API_KEY/, - ); - assert.ok(view.providers.every((chip) => chip.disabled === false)); -}); - -test('a provider error is explained on its chip', () => { - const view = presentStreetLevelPanel( - snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }), - ); - assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502'); -}); - test('errors from the viewer or the coverage web surface in one alert', () => { assert.equal(presentStreetLevelPanel(snapshot()).error, null); assert.equal( @@ -217,24 +157,6 @@ test('errors from the viewer or the coverage web surface in one alert', () => { ); }); -test('chips are dark while the layer is off, so the chip is the layer switch', () => { - const view = presentStreetLevelPanel(snapshot({ enabled: false })); - assert.deepEqual( - view.providers.map((chip) => [chip.id, chip.active, chip.state]), - [['mapillary', false, 'idle']], - ); - assert.equal(view.providers[0].title, 'Mapillary imagery off'); - assert.equal(view.enableButton, undefined, 'no separate ON/OFF button'); - const keyless = presentStreetLevelPanel( - snapshot({ providers: [provider({ keyRequired: true })] }), - ); - assert.equal( - keyless.providers[0].state, - 'error', - 'a keyless chip still says why', - ); -}); - test('the SINCE slider runs from any date on the left to the last month on the right', () => { assert.deepEqual( SINCE_STOPS.map((stop) => stop.days), @@ -272,36 +194,10 @@ test('the SINCE readout names the window and the cut-off date it means today', ( assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21'); }); -test('legend passes through in the layer’s order', () => { - const view = presentStreetLevelPanel(snapshot()); - assert.deepEqual( - view.legend.map((entry) => entry.key), - ['mapillary', 'selected'], - ); -}); - -test('each provider chip carries its source colour', () => { - const view = presentStreetLevelPanel( - snapshot({ - providers: [ - provider(), - provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }), - ], - }), - ); - assert.deepEqual( - view.providers.map((chip) => [chip.id, chip.color]), - [ - ['mapillary', '#05cb63'], - ['panoramax', '#a66bff'], - ], - ); -}); - test('the meta line never mixes the visible-sequence count with the selected sequence', () => { assert.equal( presentStreetLevelPanel(snapshot()).meta, - 'Switch a provider on to draw its coverage.', + 'Switch Street Level on to draw its coverage.', ); const browsing = presentStreetLevelPanel( snapshot({ enabled: true, coverage: { count: 812 } }), @@ -341,7 +237,6 @@ test('viewer caption reads "Image by" left, date right, and links to the provide isPano: true, externalUrl: 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', - followAvailable: true, }, }), ); @@ -352,8 +247,7 @@ test('viewer caption reads "Image by" left, date right, and links to the provide 'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo', ); assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗'); - assert.equal(view.viewer.follow.disabled, false); - assert.equal(view.wantsOpen, true); + assert.equal(view.viewer.open, true); }); test('an image without a creator name leaves the left caption empty', () => { @@ -367,24 +261,3 @@ test('an image without a creator name leaves the left caption empty', () => { assert.equal(view.viewer.link, null); assert.equal(view.viewer.linkLabel, ''); }); - -test('FOLLOW is disabled off Google 3D and says where to switch', () => { - const open = { open: true, imageId: '1' }; - const off3d = presentStreetLevelPanel( - snapshot({ street: { ...open, followAvailable: false } }), - ); - assert.equal(off3d.viewer.follow.disabled, true); - assert.match( - off3d.viewer.follow.title, - /needs the Google 3D map.*MAP SOURCE/, - ); - const on3d = presentStreetLevelPanel( - snapshot({ street: { ...open, followAvailable: true } }), - ); - assert.equal(on3d.viewer.follow.disabled, false); - assert.match(on3d.viewer.follow.title, /^Camera follows view/); - const closed = presentStreetLevelPanel( - snapshot({ street: { open: false, followAvailable: true } }), - ); - assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow'); -}); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index 8436c3a41..845d542da 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -1942,8 +1942,7 @@ body.cockpit-mode #command-dock, #military-awareness-panel, #military-awareness-direction-overlay, - #cockpit-entry, - .sl-viewer-wrap-expanded + #cockpit-entry ) { display: none !important; } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index abc2012c2..78932fc3b 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -996,9 +996,7 @@ body.ui-clean-view #data-panel, body.ui-clean-view #cctv-panel, body.ui-clean-view #radio-panel, body.ui-clean-view #global-context-panel, -body.ui-clean-view #scene-panel, -/* The expanded viewer lives on , outside every panel. */ -body.ui-clean-view .sl-viewer-wrap-expanded { +body.ui-clean-view #scene-panel { opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; diff --git a/src/ui/styles/recording.css b/src/ui/styles/recording.css index afd80cd5c..be806a87a 100644 --- a/src/ui/styles/recording.css +++ b/src/ui/styles/recording.css @@ -15,9 +15,7 @@ body.recording-mode #cctv-panel, body.recording-mode #radio-panel, body.recording-mode #global-context-panel, body.recording-mode #gev-voice-control, -body.recording-mode #scene-panel, -/* The expanded viewer lives on , outside every panel. */ -body.recording-mode .sl-viewer-wrap-expanded { +body.recording-mode #scene-panel { opacity: 0 !important; visibility: hidden !important; pointer-events: none !important; diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index e2fd98eb8..faa0588c5 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -1,8 +1,6 @@ /* ── Street Level panel ───────────────────────────────────────── - The rail owns position and width (layers.css). The expanded viewer is - re-parented to , so it carries the tokens too. */ -#street-level-panel, -.sl-viewer-wrap { + The rail owns position and width (layers.css). */ +#street-level-panel { --sl-green: #05cb63; --sl-text: 0.62rem; --sl-text-small: 0.6rem; @@ -132,56 +130,6 @@ button.sl-status:focus-visible { min-height: 0; } -/* ── Providers ───────────────────────────────────────────────────────────── */ -.sl-providers { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.sl-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -/* `--chip-color` comes from the provider. */ -.sl-chips .data-toggle-chip { - display: inline-flex; - align-items: center; - gap: 5px; -} - -.sl-chips .data-toggle-chip::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--chip-color, currentColor); - opacity: 0.45; - flex: none; -} - -.sl-chips .data-toggle-chip.active { - color: var(--chip-color, var(--accent)); - border-color: var(--chip-color, var(--accent)); - background: color-mix( - in srgb, - var(--chip-color, var(--accent)) 14%, - transparent - ); -} - -.sl-chips .data-toggle-chip.active::before { - opacity: 1; -} - -.sl-chips .data-toggle-chip.chip-error { - color: #ffb300; - border-color: rgba(255, 179, 0, 0.55); -} - /* ── Errors ──────────────────────────────────────────────────────────────── */ .sl-error { font-family: var(--font-sans); @@ -383,6 +331,10 @@ button.sl-status:focus-visible { gap: 4px; } +.sl-viewer-tools .scene-btn[hidden] { + display: none; +} + .sl-viewer-tools .sl-seg { background: rgba(10, 10, 15, 0.75); } @@ -475,65 +427,21 @@ button.sl-status:focus-visible { display: none; } -/* ── Expanded viewer ─────────────────────────────────────────────────────── */ -/* Re-parented to : the panel's backdrop-filter would contain a fixed - child. z-index sits above panels (≤139), below the voice pill (150). */ -.sl-viewer-wrap-expanded { - position: fixed; - left: 4vw; - right: 4vw; - top: 5vh; - bottom: 9vh; - z-index: 141; - display: flex; - flex-direction: column; - font-family: var(--font-mono); - border-color: rgba(0, 212, 255, 0.55); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); - outline: none; +/* ── Full screen (EXPAND) ────────────────────────────────────────────────── */ +/* The browser lifts the viewer into the top layer; the photo takes the rest. */ +.sl-viewer-wrap:fullscreen { + border: 0; + border-radius: 0; } -.sl-viewer-wrap-expanded .sl-viewer-stage { - flex: 1 1 auto; - min-height: 0; -} - -.sl-viewer-wrap-expanded .sl-viewer { +.sl-viewer-wrap:fullscreen .sl-viewer-stage, +.sl-viewer-wrap:fullscreen .sl-viewer { flex: 1 1 auto; height: auto; min-height: 0; aspect-ratio: auto; } -.sl-viewer-wrap-expanded .sl-image-meta { - font-size: 0.66rem; - padding: 10px 14px; -} - -.sl-viewer-wrap-expanded .sl-viewer-bar { - padding: 8px 10px; -} - -.sl-viewer-wrap-expanded .sl-viewer-tools .scene-btn { - padding: 8px 14px; - font-size: 0.7rem; - letter-spacing: 0.12em; - color: var(--text-primary); - border-color: rgba(0, 212, 255, 0.6); - background: rgba(10, 10, 15, 0.82); -} - -.sl-viewer-wrap-expanded #sl-viewer-expand::after { - content: ' · ESC'; - color: var(--text-secondary); -} - -.sl-viewer-wrap-expanded .sl-viewer-x { - width: 34px; - height: 34px; - font-size: 1.3rem; -} - /* ── Short viewports ─────────────────────────────────────────────────────── */ @media (max-height: 640px) { .street-level-panel-inner { diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index f6a0202e8..bf360bbb8 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -86,12 +86,11 @@