From 0da4d390eaf5b54cac0efeb269879d92e6a562bd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 26 Sep 2026 17:36:59 +0700 Subject: [PATCH 01/78] docs: add the realtime backend to the voice ownership table The ownership table lists every owner except the backend, though realtimeBackend.js owns the two requests, their separate transports, the abort/timeout scope around both, and client-secret expiry. The method signatures are already documented with application construction, so link there rather than repeat them, and state only the ownership facts written nowhere: the connection is the backend's only consumer in the application, the resolved credential is opaque to it and is handed to negotiate whole, and client-secret expiry is refused inside the backend. --- docs/VOICE-OWNERSHIP.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index d5553df00..be7a6c26b 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged. | Owner | State and responsibility | | --- | --- | | `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element | +| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry | | `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation | | `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers | | `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime | @@ -15,6 +16,15 @@ The session adapter, action runner and backend interfaces remain unchanged. | `realtimeViewport.js` | One retained image, bounded deletion identities and capture generation | | `realtimeDiagnostics.js` | Bounded error history and sanitized optional diagnostics | +The backend is replaceable per deployment: `realtimeController.js` accepts it as a +`backend` option and falls back to `createRealtimeBackend()`. Its two method +signatures live with [application construction](APPLICATION.md); what belongs here +is the ownership. The connection is the backend's only consumer in the application. +The resolved credential is opaque to the connection and is handed to `negotiate` +whole. Client-secret expiry is refused inside the backend, not by the connection. +`realtimeOwners.test.mjs` drives whole sessions through a substitute that +implements only those two methods. + Pure input policy, preferences and protocol-response policy have separate modules. Owners receive named readers, operations or focused collaborators. Cross-owner cancellation uses operations; response handling does not own Radio's reservations From 993b1bcdb1d7871a1f39b7cabc7c16387d93dd5d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:19 -0500 Subject: [PATCH 02/78] docs: add Pinokio browser performance workaround --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 88690d52f..0ff6c32e1 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ locked dependencies, finds a free local port, and opens the app. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). +**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +compatibility or minimum-hardware problem, copy the local server URL shown by +Pinokio and open the same URL in your normal hardware-accelerated browser. A +Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view +while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That isolates the browser/compositor path from the app server and your machine; +it does not establish a minimum GPU requirement. + ### Path 2 — Terminal / coding agent Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about From 4ab530b8a83c6f56b34612c1ea5c9bc07c34e3ea Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:29:21 -0500 Subject: [PATCH 03/78] docs: distinguish embedded browser performance --- docs/PERFORMANCE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 34b207f79..873ef8768 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application: 6. Record live object counts before attributing a difference to the client. 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. +8. Record whether the app is running in Pinokio's embedded view or in an + external browser. The same Pinokio 8.2.0 Linux server was reported as + severely stuttery in the embedded view and smooth when its local URL was + opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Treat that as a browser/compositor-path difference until the GPU renderer + proves otherwise. ## What is not established yet From c87f007e34a627bc9554c4bfe8070267f9c5e0b5 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 11:39:19 +0700 Subject: [PATCH 04/78] fix(build): discover unit tests under server/, not only src/ npm test is the only suite CI runs, and its discovery walked src/ alone, so server/providers/transitHistory.test.mjs has never executed since it was added. Its 8 tests pass, so this was silent coverage loss rather than a hidden failure: the file reads like covered code in review and never ran anywhere. Add server/ to the searched roots, and pin the property with a test that walks the repository itself and compares the two sets, so a tree added later without updating the roots fails loudly instead of going quiet. Suite goes from 5090 to 5099 tests, 0 failures: the 8 tests in that file plus the regression test added here. --- scripts/run-unit-tests.mjs | 14 ++++++++-- src/unitTestRunner.test.mjs | 51 ++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 9bd7e70e0..7024ea38c 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -21,9 +21,19 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { return version; } +/** + * Directories this runner searches for `*.test.mjs`, in stable order. + * + * `server/` belongs here because provider modules own tests of their own: a + * suite placed beside the module it covers must run, and `npm test` is the only + * thing CI invokes, so a directory missing from this list is a suite that never + * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test + * file in the repository, so adding a tree without adding it here fails. + */ +export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); + /** Discover repository unit tests in stable path order. */ export function discoverUnitTestFiles(root = process.cwd()) { - const sourceRoot = path.join(root, 'src'); const files = []; const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { @@ -34,7 +44,7 @@ export function discoverUnitTestFiles(root = process.cwd()) { } } }; - visit(sourceRoot); + for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot)); return files.sort(); } diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 002dcb796..201f6ed8e 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -1,14 +1,40 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ALLOCATION_TEST_FILES, + UNIT_TEST_ROOTS, allocationTestArgs, assertNode24AllocationRuntime, buildUnitTestPlan, + discoverUnitTestFiles, isCalibratedAllocationRuntime, } from '../scripts/run-unit-tests.mjs'; +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + +/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */ +function testFilesOnDisk(root) { + // Build outputs and dependencies are not places a contributor writes a test, + // and walking them would be slow enough to matter. Everything else counts. + const skip = new Set(['node_modules', 'dist', 'build', 'public']); + const found = []; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || skip.has(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile() && entry.name.endsWith('.test.mjs')) { + found.push(path.relative(root, absolute).split(path.sep).join('/')); + } + } + }; + visit(root); + return found.sort(); +} + test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => { const ordinary = [ 'src/data/manager.test.mjs', @@ -37,6 +63,29 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = ); }); +test('the runner discovers every test file in the repository', () => { + // `npm test` is the only suite CI runs, and it runs exactly what this + // function returns. A `*.test.mjs` outside the searched roots therefore never + // executes anywhere, while still reading — in review and to its author — like + // covered code. Compare the two sets rather than their sizes, so the failure + // names the files instead of only disagreeing about a count. + const discovered = discoverUnitTestFiles(repositoryRoot); + const onDisk = testFilesOnDisk(repositoryRoot); + const undiscovered = onDisk.filter((file) => !discovered.includes(file)); + assert.deepEqual( + undiscovered, + [], + `test files the runner never executes: ${undiscovered.join(', ')}`, + ); + assert.deepEqual(discovered, onDisk); + for (const file of discovered) { + assert.ok( + UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)), + `${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`, + ); + } +}); + test('allocation runtime calibration is explicit and pinned to Node 24', () => { assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0'); assert.throws( From de4c8343ddd3ce3a164e0d44e63cf3c7e805f477 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:46 -0500 Subject: [PATCH 05/78] test: make Recent Imagery live gate panel-relative --- scripts/qa-recent-imagery.mjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..f9601432f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -308,15 +308,21 @@ const CONTROLS = { }; const measure = () => page.evaluate((controls) => { + const panel = document.getElementById('recent-imagery-panel'); const body = document.getElementById('recent-imagery-panel-body'); + const panelRect = panel?.getBoundingClientRect(); const out = { bodyScroll: body?.scrollTop ?? null, rects: {} }; for (const [name, selector] of Object.entries(controls)) { const rect = document.querySelector(selector)?.getBoundingClientRect(); - out.rects[name] = rect - ? [rect.left, rect.top, rect.width, rect.height].map( - (v) => Math.round(v * 10) / 10, - ) - : null; + out.rects[name] = + rect && panelRect + ? [ + rect.left - panelRect.left, + rect.top - panelRect.top, + rect.width, + rect.height, + ].map((v) => Math.round(v * 10) / 10) + : null; } return out; }, CONTROLS); @@ -1042,13 +1048,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + 4_000, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`, + `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1264,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 8d1f69a8731b6cab3fb95f1bf6d5cd05d89f7e85 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:28 -0500 Subject: [PATCH 06/78] ci: prove Recent Imagery gate repair --- .../recent-imagery-gate-repair-proof.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml new file mode 100644 index 000000000..b613d9561 --- /dev/null +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -0,0 +1,37 @@ +name: Recent Imagery gate repair proof + +on: + push: + branches: + - fix/recent-imagery-qa-relative-layout-20261003 + +permissions: + contents: read + +jobs: + browser-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - name: Start GEV + run: | + npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & + for i in {1..90}; do + if curl -fsS http://127.0.0.1:4173/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/gev-dev.log + exit 1 + - name: Recent Imagery live gate + run: node scripts/qa-recent-imagery.mjs + - name: Dev log on failure + if: failure() + run: cat /tmp/gev-dev.log From f93755af488585bd745f75c7baa43bebbed5dc3e Mon Sep 17 00:00:00 2001 From: Bruno Gomes Date: Tue, 6 Oct 2026 16:13:45 -0300 Subject: [PATCH 07/78] fix(cctv): start live HLS on plain-HTTP LAN pages The client lease id came from crypto.randomUUID, which browsers only expose in secure contexts. Opened over http://:4173 (the HOST=0.0.0.0 opt-in), starting a live camera feed threw before playback. Fall back to a v4 UUID from crypto.getRandomValues, which the server's lease check accepts. --- CHANGELOG.md | 6 ++++ docs/CURRENT-STATE.md | 4 ++- src/layers/cctv/videoPlayback.js | 28 +++++++++++++++++- src/layers/cctv/videoPlayback.test.mjs | 39 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..9fc367d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed under NLOD 2.0. +- Live HLS cameras play on a page opened over plain HTTP from another machine + (`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which + browsers only expose in secure contexts, so starting a live feed threw + before playback. The id now falls back to a v4 UUID built from + `crypto.getRandomValues`, which the server's lease check accepts. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..fde1373af 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4814,7 +4814,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback errors revert to the labeled frame fallback. Camera changes clear the old panel frame. hls.js is imported only when an HLS camera starts. Two live sessions may coexist with at most eight independent consumer leases each; no-access lease -expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail +expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease +ids use `crypto.randomUUID` where the page is a secure context and an equivalent +v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail closed. Performance and live agency reliability require recorded validation. ## Wind forecast models diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index d59143b5a..48988c17c 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -1,3 +1,28 @@ +/** + * A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure + * contexts, so a page opened over plain HTTP from another machine (the + * HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context + * and yields the same shape the server's lease check accepts. + */ +export function createLeaseId(cryptoImpl = globalThis.crypto) { + if (typeof cryptoImpl.randomUUID === 'function') { + return cryptoImpl.randomUUID(); + } + const bytes = cryptoImpl.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10), + ] + .map((group) => group.join('')) + .join('-'); +} + /** One decoder per active camera; both surfaces consume this video element. */ export function attachCctvVideo( video, @@ -7,13 +32,14 @@ export function attachCctvVideo( loadHls = () => import('hls.js'), onFailure = () => {}, fetchImpl = globalThis.fetch, + cryptoImpl = globalThis.crypto, } = {}, ) { // Preserve replay for existing finite video feeds; live HLS must not loop. video.loop = feedType !== 'hls'; let disposed = false; let hls = null; - const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null; + const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null; const mediaUrl = feedType === 'hls' ? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}` diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 1f8c1c0bb..d1ef02539 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -1,6 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { attachCctvVideo } from './videoPlayback.js'; +import { webcrypto } from 'node:crypto'; +import { attachCctvVideo, createLeaseId } from './videoPlayback.js'; + +const LEASE_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +/** What an insecure context exposes: getRandomValues but no randomUUID. */ +const insecureCrypto = { + getRandomValues: (array) => webcrypto.getRandomValues(array), +}; function video() { const v = new EventTarget(); Object.assign(v, { @@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn assert.equal(releases[0].init.method, 'DELETE'); }); +test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => { + assert.match(createLeaseId(), LEASE_V4); + const ids = new Set(); + for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto)); + assert.equal(ids.size, 64); + for (const id of ids) assert.match(id, LEASE_V4); +}); + +test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => { + const source = video(); + source.canPlayType = () => 'probably'; + const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { + loadHls: async () => ({ default: { isSupported: () => false } }), + fetchImpl: async () => {}, + cryptoImpl: insecureCrypto, + }); + try { + await playback.ready; + const lease = new URL( + source.src, + 'http://192.168.1.10:4173/', + ).searchParams.get('lease'); + assert.match(lease, LEASE_V4); + } finally { + playback.dispose(); + } +}); + test('finite video feeds retain looping while live HLS does not loop', async () => { for (const feedType of ['mp4', 'webm', 'hls']) { const source = video(); From 6cc05a7fcbf89a6a581e8a94cd5b42b87baad8be Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:22 -0500 Subject: [PATCH 08/78] fix(ui): recut #918 src/ui/panelRails.test.mjs on current main --- src/ui/panelRails.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..de5b3997b 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -704,6 +704,25 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', () } }); +test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => { + const f = fixture('right'); + const weather = element('weather-panel', { height: 300 }); + weather.computed.display = 'none'; + f.stack.children.push(weather); + weather.parentElement = f.stack; + + f.run(); + + assert.equal(f.stack.classList.contains('layout-exclusive'), false); + assert.equal(f.stack.dataset.expandedCount, '0'); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.second.getAttribute('aria-hidden'), undefined); + assert.equal( + weather.style.getPropertyValue('--right-panel-allocated-height'), + '', + ); +}); + test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); From 21a90b41442af3a1f7c56d86c95c376a174e92dd Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:25 -0500 Subject: [PATCH 09/78] fix(ui): recut #918 src/ui/rightPanelRail.js on current main --- src/ui/rightPanelRail.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..b62be511c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -72,7 +72,9 @@ export function layoutRightPanelRail({ // 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.matches('[data-panel-id]:not(.panel-floating)') && + !panel.hidden && + getComputedStyle(panel).display !== 'none', ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => From 123bf55f39a7dc12cad6215b362da0acc91c6c23 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:30 -0700 Subject: [PATCH 10/78] fix(qa): repoint stale mutation anchors after the September refactors The three mutation gates matched source text that has since moved or been reflowed, so 37 of their 108 reverts reported STALE and could no longer prove anything: - qa-floorhold-mutations (2): the neighbour-floor code moved from src/data/groundFloor.js into src/services/groundFloor.js (ac977bd). - qa-flyroute-mutations (8): src/cameraVerbs.js was reflowed by the formatter (2aca7ae); the anchors now match the formatted lines. - qa-firstrun-mutations (27): the launcher markup, styles and startup moved to src/ui/templates/welcome.html, src/ui/styles/first-run.css and src/app/startupChrome.js; the rail default to panelPositionControls.js and the radio ESC handling to radioBindings.js (a67bc60 and follow-ups). The tool-schema pin now edits a tool description, since the context-mode enum it used to edit no longer lives in tools.js. Every revert still reintroduces the same defect. Results: floor-hold 22/22, fly_route 19/19, first-run 67/67 caught, with all source files restored. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-firstrun-mutations.mjs | 68 ++++++++++++++++-------------- scripts/qa-floorhold-mutations.mjs | 10 ++--- scripts/qa-flyroute-mutations.mjs | 20 ++++----- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index b8b7c9f5e..3ef331abd 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome. const FILES = { keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'), module: path.join(ROOT, 'src', 'firstRunExperience.js'), - html: path.join(ROOT, 'index.html'), - css: path.join(ROOT, 'style.css'), + html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'), + css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'), voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'), - voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'), - main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'), - ui: path.join(ROOT, 'src', 'ui.js'), + voiceDescriptions: path.join( + ROOT, + 'server/providers/openai/toolDescriptions.js', + ), + main: path.join(ROOT, 'src', 'app', 'startupChrome.js'), + ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'), + radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'), docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'), }; @@ -63,14 +67,14 @@ const MUTATIONS = [ { defect: 'the session flag is never read, so it re-nags on every reload', file: 'module', - from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;", - to: '// session check removed', + from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;", + to: ' // session check removed', }, { defect: 'the durable flag is never read, so the checkbox does nothing', file: 'module', - from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;", - to: '// durable check removed', + from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;", + to: ' // durable check removed', }, { defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it', @@ -105,7 +109,7 @@ const MUTATIONS = [ { defect: 'the guarded resolver stops catching, so a hostile getter escapes', file: 'module', - from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", + from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }", to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;", }, { @@ -131,8 +135,8 @@ const MUTATIONS = [ { defect: 'nothing watches for another surface taking the screen', file: 'module', - from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });", - to: '/* observer never attached */', + from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });", + to: ' /* observer never attached */', }, { defect: 'cockpit drops off the exclusive list and can stack with the launcher', @@ -155,8 +159,8 @@ const MUTATIONS = [ { defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again', file: 'module', - from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();', - to: ' && root.getClientRects().length > 0;', + from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();', + to: ' root.getClientRects().length > 0;', }, { defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working', @@ -174,9 +178,9 @@ const MUTATIONS = [ // Anchored past the call so it cannot land on the cockpit disclosure's own // stopImmediatePropagation() a few lines below and prove nothing about this. defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things', - file: 'ui', - from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', - to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn', + file: 'radio', + from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', + to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||', }, { defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind', @@ -234,7 +238,7 @@ const MUTATIONS = [ { defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump', file: 'module', - from: " environmental: Object.freeze({", + from: ' environmental: Object.freeze({', to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({", }, { @@ -326,8 +330,8 @@ const MUTATIONS = [ { defect: 'the Context panel is left collapsed, hiding the mode that just started', file: 'module', - from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });", - to: '// panel reveal removed', + from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });", + to: ' // panel reveal removed', }, { defect: 'the decision table is deleted, so the next editor re-litigates it blind', @@ -340,8 +344,8 @@ const MUTATIONS = [ { defect: 'disabling the focused tile drops the keyboard to mid-flight', file: 'module', - from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));", - to: 'for (const button of buttons) button.disabled = next;', + from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));", + to: ' for (const button of buttons) button.disabled = next;', }, { defect: 'Tab escapes into an app the visitor has not seen yet', @@ -370,8 +374,8 @@ const MUTATIONS = [ { defect: 'the card stays clickable through its fade-out (double-launch)', file: 'css', - from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', - to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', + from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;', + to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);', }, { defect: 'the launcher survives Clean-UI and recording mode', @@ -403,8 +407,8 @@ const MUTATIONS = [ // those instead and prove nothing about this feature. defect: "the launcher's reduced-motion opt-out is dropped", file: 'css', - from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }', - to: ' .first-run-nothing { transition: none; }', + from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }', + to: ' .first-run-nothing {\n transition: none;\n }', }, // ── Markup and startup ordering ─────────────────────────────────────────── @@ -442,21 +446,21 @@ const MUTATIONS = [ defect: 'the launcher is revealed before the loading cover yields', file: 'main', from: " loadingScreen.classList.add('hidden');", - to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');', + to: " revealFirstRun();\n loadingScreen.classList.add('hidden');", }, { defect: 'the globe missions lose their DataManager and cannot enable layers', file: 'main', - from: 'initFirstRunExperience({ styleManager, dataManager });', - to: 'initFirstRunExperience({ styleManager });', + from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });', + to: 'firstRun = initializeWelcome?.({ styleManager });', }, // ── Voice: schema must not drift ────────────────────────────────────────── { defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)', - file: 'voiceTools', - from: " 'earthquakes',\n 'satellites',", - to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',", + file: 'voiceDescriptions', + from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers", + to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers", }, { defect: 'the instruction mapping is dropped, so voice cannot reach the modes', diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 0e2ac9d62..b193ff169 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -26,7 +26,7 @@ const TESTS = [ ]; const FLIGHTS = 'src/layers/flights/motion.js'; -const FLOOR = 'src/data/groundFloor.js'; +const FLOOR = 'src/services/groundFloor.js'; const ALT = 'src/data/renderAltitude.js'; /** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */ @@ -133,8 +133,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' if (lowest == null || h < lowest) lowest = h;', - to: ' if (lowest == null || h > lowest) lowest = h;', + from: ' if (lowest == null || h < lowest) lowest = h;', + to: ' if (lowest == null || h > lowest) lowest = h;', }, ], }, @@ -143,8 +143,8 @@ const MUTATIONS = [ edits: [ { file: FLOOR, - from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', - to: ' return resolved >= 1 ? lowest : null;', + from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;', + to: ' return resolved >= 1 ? lowest : null;', }, ], }, diff --git a/scripts/qa-flyroute-mutations.mjs b/scripts/qa-flyroute-mutations.mjs index dbece6dba..d7d3d3325 100644 --- a/scripts/qa-flyroute-mutations.mjs +++ b/scripts/qa-flyroute-mutations.mjs @@ -35,17 +35,17 @@ const MUTATIONS = [ }, { defect: 'heading lerps in Cartesian space and cannot cross a U-turn', - from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);', + from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );', to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);', }, { defect: 'the exact-180° bank direction becomes a floating-point coin toss', - from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;', + from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;', to: ' // branch removed', }, { defect: 'a cold cell is read as sea level (the 1.3 km underground bug)', - from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;', + from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;', to: ' state.floorM = carto.height;', }, { @@ -55,12 +55,12 @@ const MUTATIONS = [ }, { defect: 'the dolly races the fire-and-forget warm instead of arming for it', - from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {', + from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {', to: ' if (false) {', }, { defect: 'no mesh probe, so a permanently cold DEM is flown blind', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {', + from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {', to: ' if (false) {', }, { @@ -90,8 +90,8 @@ const MUTATIONS = [ }, { defect: 'the mesh probe fires every arming frame instead of once per route', - from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', - to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;', + from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', + to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;', }, { defect: 'one warm cell resolves a corridor whose other cells are unknown', @@ -105,13 +105,13 @@ const MUTATIONS = [ }, { defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample', - from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,', + from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,', to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,', }, { defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)', - from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', - to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });', + from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', + to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });', }, { defect: 'the OS reduced-motion preference is never consulted', From fa53eec53dd81622e68244d47c29471565e3d349 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:25 -0400 Subject: [PATCH 11/78] fix(search): refuse empty coordinate components instead of silently dropping them --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/search/coordinate-empty-components.test.mjs | 12 ++++++++++++ src/search/coordinateParser.js | 4 ++-- 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/search/coordinate-empty-components.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..598588ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..b490d308f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5127,3 +5127,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(search): refuse empty coordinate components instead of silently dropping them + +Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. diff --git a/src/search/coordinate-empty-components.test.mjs b/src/search/coordinate-empty-components.test.mjs new file mode 100644 index 000000000..48bca54ed --- /dev/null +++ b/src/search/coordinate-empty-components.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCoordinateQuery } from './coordinateParser.js'; +test('search refuses omitted components and repeated separators', () => { + for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97']) + assert.equal(parseCoordinateQuery(q), null, q); + for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97']) + assert.deepEqual( + [parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon], + [30, -97], + ); +}); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..cb8c85524 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -49,8 +49,8 @@ function splitComponents(query) { const parts = /[,;]/.test(text) ? text.split(/[,;]/) : text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i); - const trimmed = parts.map((part) => part.trim()).filter(Boolean); - return trimmed.length === 2 ? trimmed : null; + const trimmed = parts.map((part) => part.trim()); + return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; } /** Present a parsed pair the way a coordinate is normally written. */ From 7bbf0aa55af84a6382283eff4640f2ddecde5f2e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:41 -0400 Subject: [PATCH 12/78] fix: address review feedback for PR #1022 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b490d308f..8e4f58af1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4143,6 +4143,8 @@ and unreachable upstream (502/504) separately from road geometry. ### Place-search providers +Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location. + Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes two offline providers first — a strict decimal-degree coordinate reader and an @@ -5127,8 +5129,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(search): refuse empty coordinate components instead of silently dropping them - -Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. From 9501dc121bce1552c4ce50dfa4d448064165f97e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:01 -0500 Subject: [PATCH 13/78] test: preserve strict default timing with opt-in slow CI gate --- .../recent-imagery-gate-repair-proof.yml | 2 ++ scripts/qa-recent-imagery.mjs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml index b613d9561..d93925e8c 100644 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -31,6 +31,8 @@ jobs: cat /tmp/gev-dev.log exit 1 - name: Recent Imagery live gate + env: + QA_RECENT_IMAGERY_SLOW_RENDER: '1' run: node scripts/qa-recent-imagery.mjs - name: Dev log on failure if: failure() diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f9601432f..f81209b9f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,8 +12,10 @@ * Run: start the app (`./scripts/dev-fresh.sh`) and * node scripts/qa-recent-imagery.mjs * Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default - * qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without - * network access to NASA the run skips with exit code 0. + * qa-shots/recent-imagery), QA_HEADFUL=1. + * QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a + * 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s). + * Every wait is bounded. Without NASA access the run skips with code 0. */ import fs from 'node:fs'; import path from 'node:path'; @@ -22,6 +24,9 @@ import puppeteer from 'puppeteer'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; const HEADFUL = process.env.QA_HEADFUL === '1'; +const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1'; +const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000; +const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12; const STEP_TIMEOUT = 20_000; const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 }; const PANEL = '#recent-imagery-panel'; @@ -1048,13 +1053,13 @@ try { ) <= target * 0.01, fitHeight, - 4_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, + `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1264,7 +1269,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 9d59e555c2b1bd2053296561f9dc01ba64ed551b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:47 -0500 Subject: [PATCH 14/78] style: format slow-CI timing assertions --- scripts/qa-recent-imagery.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f81209b9f..9c1d6700e 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1059,7 +1059,7 @@ try { () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1269,7 +1269,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); + const tail = (key) => + new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key])); return { settled: tail('imagery').size === 1 && tail('cctv').size === 1, detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`, From 2357a1140907720f6bfc7037a992196e83057dfb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:36:02 -0500 Subject: [PATCH 15/78] ci: remove fork-only Recent Imagery proof workflow per review --- .../recent-imagery-gate-repair-proof.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml deleted file mode 100644 index d93925e8c..000000000 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Recent Imagery gate repair proof - -on: - push: - branches: - - fix/recent-imagery-qa-relative-layout-20261003 - -permissions: - contents: read - -jobs: - browser-proof: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - name: Start GEV - run: | - npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & - for i in {1..90}; do - if curl -fsS http://127.0.0.1:4173/ >/dev/null; then - exit 0 - fi - sleep 1 - done - cat /tmp/gev-dev.log - exit 1 - - name: Recent Imagery live gate - env: - QA_RECENT_IMAGERY_SLOW_RENDER: '1' - run: node scripts/qa-recent-imagery.mjs - - name: Dev log on failure - if: failure() - run: cat /tmp/gev-dev.log From 3cf9a8d65dace4704eacd58dc033074007ad2f87 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:18:26 +0000 Subject: [PATCH 16/78] feat(cctv): add NYC DOT Traffic Management Center camera pack Register the keyless webcams.nyctmc.org catalog as a live CCTV source, synthesize frame URLs from validated camera UUIDs, and keep the nearest cameras to the five borough cores under the existing catalog ceiling. --- .env.example | 3 + CHANGELOG.md | 9 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 17 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 30 +++ server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 153 ++++++++++++ src/data/cctvNyc.test.mjs | 385 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 + 12 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvNyc.test.mjs diff --git a/.env.example b/.env.example index c89680b30..0b9a0037a 100644 --- a/.env.example +++ b/.env.example @@ -215,6 +215,9 @@ AISSTREAM_API_KEY= # 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_URL= # override the Statens vegvesen catalog URL +# CCTV_NYC_ENABLED=1 # set 0 to disable the NYC DOT Traffic Management Center cameras +# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) +# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..0b12ee132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Add New York City traffic cameras from the NYC DOT Traffic Management Center + as a CCTV pack. The camera list is one keyless JSON request to + `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each + camera UUID on that same host, so a catalog field cannot steer the proxy + off-host. Offline cameras are dropped. By default the 250 cameras nearest + the five borough cores load. `CCTV_NYC_ENABLED=0` turns the pack off and + `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..dbb07f0bf 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,6 +56,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" | +| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | Public NYC DOT traffic camera stills | "NYC DOT Traffic Management Center" (courtesy) | | **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) | @@ -91,6 +92,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. +- **NYC DOT Traffic Management Center cameras.** Keyless. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap and `CCTV_NYC_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. - **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/README.md b/README.md index 5d97dfb2f..38f7a4913 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **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 | 🟢 | +| 📹 **CCTV Mesh** | ~4,100 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) · New York City (NYC DOT TMC). 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 | | 📻 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..38bfe3a20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -914,6 +914,21 @@ owners. Tuner calculations have a pure entry, with existing layer exports preserved. Destruction removes listeners and state subscriptions before ending tuning; a delayed Enable result cannot reveal or refocus removed controls. +## NYC DOT TMC CCTV source pack + +The CCTV catalog adds New York City traffic cameras from the NYC DOT Traffic +Management Center alongside the other live packs. The server reads the keyless +camera list at `https://webcams.nyctmc.org/api/cameras` and keeps cameras the +feed marks online. Frame URLs are built from each camera UUID under +`https://webcams.nyctmc.org/api/cameras//image`; they are never taken +from the payload. There is no heading in the feed, so gizmos use the shared +id-hash fallback at low confidence. + +By default the 250 cameras nearest the five borough cores load. +`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400) and `CCTV_NYC_ENABLED=0` +turns the pack off. The catalog-wide ceiling stays at 4,000 and is shared +round-robin, so adding this pack does not silently drop another region. + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the @@ -3058,7 +3073,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) + NYC DOT TMC + 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/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..388028925 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -532,6 +532,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvVegvesen.test.mjs", + "src/data/cctvNyc.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..0939a71da 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadNycSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'nyc', + enabled: () => envEnabled('CCTV_NYC_ENABLED'), + load: loadNycSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..ae80904a5 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,36 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the + * five boroughs (~980 cameras). Frames are stills synthesized from each + * camera UUID on the city's webcam host — never taken from the payload. */ +export const NYC_CAMERAS_URL = 'https://webcams.nyctmc.org/api/cameras'; +/** The only origin NYC camera frames may come from. The catalog's `imageUrl` + * is ignored; the registered path is always this origin plus a validated + * UUID plus `/image`. */ +export const NYC_IMAGE_ORIGIN = 'https://webcams.nyctmc.org/api/cameras/'; +export const NYC_IMAGE_URL = (id) => `${NYC_IMAGE_ORIGIN}${id}/image`; +export const NYC_CAMERA_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const DEFAULT_NYC_MAX_SOURCES = 250; +/** Hard ceiling on the NYC catalog body. The live list is ~980 rows / ~250 KB; + * this only exists so an upstream that streams an unbounded body cannot be + * buffered without limit. */ +export const NYC_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Ground-elevation prior in metres. Matches the New York CITY_POIS prior; + * the client's one-shot ground snap corrects it wherever 3D tiles load. */ +export const NYC_GROUND_ELEVATION_M = 10; +/** Prioritization anchors: one per borough so a cap keeps citywide coverage + * rather than Midtown alone. */ +export const NYC_ANCHORS = [ + { lat: 40.758, lon: -73.9855 }, // Manhattan (Times Square) + { lat: 40.7128, lon: -74.006 }, // Lower Manhattan + { lat: 40.6782, lon: -73.9442 }, // Brooklyn + { lat: 40.7282, lon: -73.7949 }, // Queens + { lat: 40.8448, lon: -73.8648 }, // Bronx + { lat: 40.5795, lon: -74.1502 }, // Staten Island +]; + /** 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/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..99b685f44 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) { ); } +/** New York City's five boroughs, with slack for the harbor and city line. */ +export function isLikelyNycCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.45 && + lat <= 40.95 && + lon >= -74.3 && + lon <= -73.65 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..43ba30994 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,13 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + NYC_CAMERAS_URL, + NYC_IMAGE_URL, + NYC_CAMERA_ID_RE, + DEFAULT_NYC_MAX_SOURCES, + NYC_MAX_CATALOG_BYTES, + NYC_GROUND_ELEVATION_M, + NYC_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -82,6 +89,7 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyNycCoordinate, isLikelyNorwayCoordinate, cameraDisplayCode, rowArrayToObject, @@ -1863,3 +1871,148 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +const NYC_BOROUGHS = new Set([ + 'Manhattan', + 'Brooklyn', + 'Queens', + 'Bronx', + 'Staten Island', +]); + +/** NYC TMC publishes `isOnline` as the strings "true"/"false". */ +export function nycCameraIsOnline(value) { + return ( + value === true || + String(value ?? '') + .trim() + .toLowerCase() === 'true' + ); +} + +/** + * Label for one NYC camera: the intersection an operator recognises + * ("Central Park West @ 86 St"). The catalog's `area` is the borough, not + * a facing, and is not part of the name. + * + * @param {object} record - Raw TMC camera row. + * @param {string} cameraId - Derived stable id. + * @returns {string} + */ +export function nycCameraName(record, cameraId) { + const name = String(record?.name ?? '').trim(); + if (name) return name; + const short = String(cameraId || '') + .replace(/^nyc-/, '') + .slice(0, 8); + return short ? `NYC Camera ${short}` : 'NYC Camera'; +} + +/** + * One NYC DOT TMC camera row -> one catalog source, or null. + * + * Frame URLs are synthesized from a validated UUID on the pinned TMC image + * origin. The payload's `imageUrl` is ignored so a catalog edit cannot steer + * the frame proxy off-host. There is no heading in the feed; headings use + * the shared id-hash fallback at low confidence, like Calgary and Fintraffic. + * + * @param {object} record - Raw TMC camera row. + * @returns {?object} + */ +export function nycCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + if (!nycCameraIsOnline(record.isOnline)) return null; + + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyNycCoordinate(lat, lon)) return null; + + const uuid = String(record.id ?? '').trim(); + if (!NYC_CAMERA_ID_RE.test(uuid)) return null; + const cameraId = `nyc-${uuid.toLowerCase()}`; + const imageUrl = NYC_IMAGE_URL(uuid); + const name = nycCameraName(record, cameraId); + const area = String(record.area ?? '').trim(); + const city = NYC_BOROUGHS.has(area) ? area : 'New York'; + + return { + id: cameraId, + name, + city, + cityId: 'nyc', + provider: 'NYC DOT Traffic Management Center', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: NYC_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'nyc-dot-tmc', + license: 'Public NYC DOT Traffic Management Center traffic camera', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch NYC DOT Traffic Management Center cameras from the keyless + * `webcams.nyctmc.org` catalog. Frames are stills on that same host, + * registered from a validated UUID rather than from the payload URL. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadNycSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_NYC_CAMERAS_URL || NYC_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] NYC catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] NYC camera download failed:', resp.status); + return discard(); + } + const rows = await readResponseJsonCapped(resp, NYC_MAX_CATALOG_BYTES); + if (!Array.isArray(rows)) return []; + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = nycCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_NYC_MAX_SOURCES || DEFAULT_NYC_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_NYC_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, NYC_ANCHORS); + console.log( + `[CCTV] Loaded NYC camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] NYC camera download error:', error?.message || error); + return []; + } +} diff --git a/src/data/cctvNyc.test.mjs b/src/data/cctvNyc.test.mjs new file mode 100644 index 000000000..d67ffa002 --- /dev/null +++ b/src/data/cctvNyc.test.mjs @@ -0,0 +1,385 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + loadNycSourcesFromOpenData, + nycCameraIsOnline, + nycCameraName, + nycCameraToSource, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_NYC_MAX_SOURCES, + NYC_CAMERAS_URL, + NYC_GROUND_ELEVATION_M, + NYC_IMAGE_ORIGIN, + NYC_IMAGE_URL, + NYC_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + isLikelyNycCoordinate, +} from '../../server/providers/cctv/normalize.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1'; +const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555'; + +/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */ +const row = (overrides = {}) => ({ + id: MIDTOWN_ID, + name: 'Central Park West @ 86 St', + latitude: 40.785302, + longitude: -73.969353, + area: 'Manhattan', + isOnline: 'true', + imageUrl: NYC_IMAGE_URL(MIDTOWN_ID), + ...overrides, +}); + +test('a NYC row maps to a source on the pinned HTTPS frame host', () => { + const source = nycCameraToSource(row()); + assert.equal(source.id, `nyc-${MIDTOWN_ID}`); + assert.equal(source.name, 'Central Park West @ 86 St'); + assert.equal(source.city, 'Manhattan'); + assert.equal(source.cityId, 'nyc'); + assert.equal(source.provider, 'NYC DOT Traffic Management Center'); + assert.equal(source.lat, 40.785302); + assert.equal(source.lon, -73.969353); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'nyc-dot-tmc'); + assert.equal( + source.license, + 'Public NYC DOT Traffic Management Center traffic camera', + ); + assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M); + assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID)); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN)); + assert.equal(source.headingConfidence, 'low'); + assert.ok(Number.isFinite(source.headingDeg)); +}); + +test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => { + const source = nycCameraToSource( + row({ imageUrl: 'https://evil.example/cameras/x/image' }), + ); + assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('offline cameras, off-city geometry and unusable ids are dropped', () => { + assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null); + assert.equal(nycCameraToSource(row({ isOnline: false })), null); + assert.equal( + nycCameraToSource(row({ isOnline: 'TRUE' })).id, + `nyc-${MIDTOWN_ID}`, + ); + assert.equal( + nycCameraToSource(row({ isOnline: true })).id, + `nyc-${MIDTOWN_ID}`, + ); + // Austin: a plausible lat/lon, but not a New York camera. + assert.equal( + nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })), + null, + ); + assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null); + assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null); + assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null); + assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null); + assert.equal(nycCameraToSource(null), null); +}); + +test('ids lower-case the UUID and unknown boroughs fall back to New York', () => { + const mixed = nycCameraToSource( + row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }), + ); + assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`); + assert.equal(mixed.city, 'New York'); + assert.equal( + mixed.url, + NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()), + 'the registered frame path keeps the catalog UUID spelling', + ); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'), + 'FDR Drive @ 23 St', + ); + assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417'); + assert.equal(nycCameraIsOnline('true'), true); + assert.equal(nycCameraIsOnline('false'), false); +}); + +test('the unselected label is the intersection, trimmed to the code width', () => { + assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST'); + const long = nycCameraToSource( + row({ + name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension', + }), + ); + assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); + assert.ok(long.code.startsWith('BROOKLYN')); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); + return Response.json([ + row({ latitude: 40.758, longitude: -73.9855 }), + row({ + name: 'Central Park West @ 86 St (duplicate)', + latitude: 40.758, + longitude: -73.9855, + }), + row({ + id: BROOKLYN_ID, + name: 'Atlantic Ave @ Flatbush Ave', + latitude: 40.65, + longitude: -73.96, + area: 'Brooklyn', + imageUrl: NYC_IMAGE_URL(BROOKLYN_ID), + }), + row({ isOnline: 'false', name: 'Offline camera' }), + row({ id: 'https://evil.example/x' }), + ]); + }); + const cameras = await loadNycSourcesFromOpenData(); + assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]); + assert.deepEqual( + cameras.map((camera) => camera.id), + // Nearest to a borough anchor first: the Times Square camera before Brooklyn. + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + ); + assert.equal(cameras[0].name, 'Central Park West @ 86 St'); + assert.equal(cameras[1].city, 'Brooklyn'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.json' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = JSON.stringify([row()]); + return new Response(body, { + headers: { + 'Content-Type': 'application/json', + 'content-length': String(NYC_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); +}); + +test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => { + t.mock.method(console, 'log', () => {}); + const saved = process.env.CCTV_NYC_MAX_SOURCES; + t.after(() => { + if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES; + else process.env.CCTV_NYC_MAX_SOURCES = saved; + }); + process.env.CCTV_NYC_MAX_SOURCES = '8'; + const rows = [ + // Far southern Staten Island, listed first so only distance can keep them. + ...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) => + row({ + id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`, + latitude: lat, + longitude: -74.24, + area: 'Staten Island', + name: `SI ${i}`, + }), + ), + ...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) => + row({ + id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`, + latitude: lat, + longitude: -73.9855, + area: 'Manhattan', + name: `Midtown ${i}`, + }), + ), + ]; + t.mock.method(globalThis, 'fetch', async () => Response.json(rows)); + const cameras = await loadNycSourcesFromOpenData(); + assert.equal(cameras.length, 8); + assert.deepEqual( + cameras.map((camera) => camera.city), + [ + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Staten Island', + 'Staten Island', + ], + 'Midtown fills first; the Staten Island anchor still keeps a share', + ); +}); + +/** + * Serve the NYC catalog to the NYC endpoint and an empty payload to every + * other pack, so one catalog refresh exercises the registration without + * reaching the network. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === NYC_CAMERAS_URL) { + return Response.json([ + row({ latitude: 40.758, longitude: -73.9855 }), + row({ + id: BROOKLYN_ID, + name: 'Atlantic Ave @ Flatbush Ave', + latitude: 40.65, + longitude: -73.96, + area: 'Brooklyn', + }), + ]); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the NYC lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_NYC_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(NYC_CAMERAS_URL), + 'the catalog refresh invokes the NYC loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + 'NYC cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_NYC_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_NYC_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(NYC_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('the shipped catalog ceiling is not raised to make room for this pack', () => { + assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); + assert.equal(DEFAULT_NYC_MAX_SOURCES, 250); +}); + +test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => { + assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square + assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island + assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx + assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack + assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin + assert.equal(isLikelyNycCoordinate(0, 0), false); + assert.equal(isLikelyNycCoordinate(NaN, -73.98), false); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..891cd21e2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,12 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'nyc-cctv', + html: + 'CCTV cameras & frames (New York City): ' + + 'NYC DOT Traffic Management Center', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From e5eac0c7f898864090211ebd096924cf13743bc5 Mon Sep 17 00:00:00 2001 From: hifiguy <34317110+hifiguy@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:57:15 -0400 Subject: [PATCH 17/78] fix: request HTTPS tiles for ion-backed Bing imagery --- CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 6 ++ src/maps/imagery.js | 37 +++++++ src/maps/sourceFactories.test.mjs | 158 +++++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..6c59600a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through + their ion-backed providers, retaining ion and per-tile attribution without + widening the Content Security Policy. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..f68d8a194 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5018,6 +5018,12 @@ photoreal globe is hidden. Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. +Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied +token, then construct the Bing provider with `tileProtocol: 'https'`. Both styles +request HTTPS tiles even on HTTP localhost, with the existing Content Security +Policy unchanged. Ion attribution and Bing's per-tile credits are preserved; +other ion imagery assets continue through the SDK's standard ion factory. + The controller accepts other source registries without adding provider branches. Each instance caches source construction, ignores superseded scene changes and releases imagery layers/listeners on replacement. Destroy invalidates pending diff --git a/src/maps/imagery.js b/src/maps/imagery.js index 32fd3f375..d151b6ce6 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -25,5 +25,42 @@ export function createEsriImagery() { export function createIonImagery(style, accessToken) { accessToken = String(accessToken || '').trim(); if (!accessToken) throw new Error('Ion imagery requires an explicit token'); + if ( + style === Cesium.IonWorldImageryStyle.AERIAL || + style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS + ) { + return createBingIonImagery(style, accessToken); + } return Cesium.IonImageryProvider.fromAssetId(style, { accessToken }); } + +async function createBingIonImagery(style, accessToken) { + const endpoint = await new Cesium.Resource({ + url: `https://api.cesium.com/v1/assets/${style}/endpoint`, + queryParameters: { access_token: accessToken }, + }).fetchJson(); + if ( + endpoint?.type !== 'IMAGERY' || + endpoint.externalType !== 'BING' || + typeof endpoint.options?.url !== 'string' + ) { + throw new Error('Ion world imagery did not return a Bing imagery endpoint'); + } + // Bing otherwise follows the page's HTTP scheme on localhost. Resolve its + // ion-issued credentials through public APIs so tiles use HTTPS without + // weakening CSP, changing SDK defaults or touching provider internals. + const { url, ...options } = endpoint.options; + const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, { + ...options, + tileProtocol: 'https', + }); + const ionCredits = (endpoint.attributions ?? []).map( + ({ html, collapsible }) => new Cesium.Credit(html, collapsible === false), + ); + const getTileCredits = provider.getTileCredits.bind(provider); + provider.getTileCredits = (x, y, level) => [ + ...ionCredits, + ...(getTileCredits(x, y, level) ?? []), + ]; + return provider; +} diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 15571666d..d3aaa5068 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul calls.push({ kind: 'terrain', resource, options }); return { id: 'terrain' }; }; - await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token'); + await createIonImagery(12345, 'imagery-token'); const result = await createWorldTerrain('terrain-token'); assert.equal(calls[0].options.accessToken, 'imagery-token'); assert.equal(calls[1].options.accessToken, 'terrain-token'); @@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul } }); +test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => { + const defaultToken = Cesium.Ion.defaultAccessToken; + const metadataCalls = []; + const bingCalls = []; + const perTileCalls = []; + const bingCredit = new Cesium.Credit('Bing tile credit', true); + const endpoint = { + type: 'IMAGERY', + externalType: 'BING', + options: Object.freeze({ + url: 'https://dev.virtualearth.net', + key: 'ion-issued-bing-fixture', + mapStyle: 'Aerial', + tileProtocol: 'http', + }), + attributions: [ + { html: 'Ion required credit', collapsible: false }, + { html: 'Ion optional credit', collapsible: true }, + ], + }; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () { + metadataCalls.push({ + url: this.url, + token: this.queryParameters.access_token, + }); + const mapStyle = this.url.includes( + `/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`, + ) + ? 'AerialWithLabels' + : 'Aerial'; + return { + ...endpoint, + options: Object.freeze({ ...endpoint.options, mapStyle }), + }; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing must not inherit the local HTTP page protocol'), + ); + t.mock.method( + Cesium.BingMapsImageryProvider, + 'fromUrl', + async (url, options) => { + bingCalls.push({ url, options }); + const provider = { + errorEvent: new Cesium.Event(), + getTileCredits(x, y, level) { + assert.equal(this, provider); + perTileCalls.push([x, y, level]); + return level === 0 ? undefined : [bingCredit]; + }, + }; + return provider; + }, + ); + for (const style of [ + Cesium.IonWorldImageryStyle.AERIAL, + Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS, + ]) { + const provider = await createIonImagery(style, ' own-ion-token '); + const credits = provider.getTileCredits(3, 4, 5); + assert.deepEqual( + credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })), + [ + { html: 'Ion required credit', showOnScreen: true }, + { html: 'Ion optional credit', showOnScreen: false }, + { html: 'Bing tile credit', showOnScreen: true }, + ], + ); + assert.equal(credits[2], bingCredit); + assert.equal(provider.getTileCredits(0, 0, 0).length, 2); + assert.ok(provider.errorEvent instanceof Cesium.Event); + assert.equal(metadataCalls.at(-1).token, 'own-ion-token'); + const requested = new URL(metadataCalls.at(-1).url); + assert.equal(requested.origin, 'https://api.cesium.com'); + assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`); + } + assert.deepEqual(perTileCalls, [ + [3, 4, 5], + [0, 0, 0], + [3, 4, 5], + [0, 0, 0], + ]); + assert.equal(bingCalls.length, 2); + for (const [index, { url, options }] of bingCalls.entries()) { + assert.equal(url, 'https://dev.virtualearth.net'); + assert.deepEqual(options, { + key: 'ion-issued-bing-fixture', + mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels', + tileProtocol: 'https', + }); + } + assert.equal(endpoint.options.tileProtocol, 'http'); + assert.equal(Cesium.Ion.defaultAccessToken, defaultToken); +}); + +test('Bing ion metadata and construction failures propagate without another source', async (t) => { + const style = Cesium.IonWorldImageryStyle.AERIAL; + const metadataError = new Error('ion metadata fixture failure'); + const providerError = new Error('Bing construction fixture failure'); + let metadata = () => { + throw metadataError; + }; + let bingCalls = 0; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata()); + t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => { + bingCalls += 1; + throw providerError; + }); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () => + assert.fail('Bing failures must not silently use an alternate provider'), + ); + await assert.rejects(createIonImagery(style, 'test-token'), metadataError); + for (const endpoint of [ + undefined, + { + type: 'TERRAIN', + externalType: 'BING', + options: { url: 'https://example.test' }, + }, + { + type: 'IMAGERY', + externalType: 'OTHER', + options: { url: 'https://example.test' }, + }, + { type: 'IMAGERY', externalType: 'BING', options: {} }, + ]) { + metadata = () => endpoint; + await assert.rejects( + createIonImagery(style, 'test-token'), + /Bing imagery endpoint/, + ); + } + assert.equal(bingCalls, 0); + metadata = () => ({ + type: 'IMAGERY', + externalType: 'BING', + options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' }, + }); + await assert.rejects(createIonImagery(style, 'test-token'), providerError); + assert.equal(bingCalls, 1); +}); + +test('other ion imagery assets keep the standard SDK factory', async (t) => { + const expected = {}; + const calls = []; + t.mock.method(Cesium.Resource.prototype, 'fetchJson', () => + assert.fail('generic assets must use the SDK ion factory'), + ); + t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => { + calls.push(args); + return expected; + }); + assert.equal(await createIonImagery(12345, ' custom-token '), expected); + assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]); +}); + test('cancellation after ion metadata prevents terrain construction', async () => { const originalResource = Cesium.IonResource.fromAssetId; const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl; From cb01014e36948c48f9664c9a93686c63365c5d06 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 18:33:14 +0100 Subject: [PATCH 18/78] feat(street-level): KartaView as a third, keyless imagery provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage lines from KartaView's z13-14 JSON tiles (OSM ways with photos, chained end to start), the photo nearest a clicked line, cones along its sequence, and a viewer for flat photos and 360° panoramas (a small WebGL equirectangular renderer, no new dependency) with ‹ › along the sequence. Mapillary's camera-driven line coverage and sequence cones move to the provider-neutral core (lineCoverage.js, sequenceCones.js) with the tile fetch, zoom ladder, colours and pick prefixes injected; Mapillary keeps thin wrappers and its tests pass unchanged. KartaView quirks handled: api.kartaview.org (the documented api.openstreetcam.org serves empty tiles), photo URLs that name storage1 for photos stored elsewhere, and a photo API that answers 429 to more than about three requests at once (two in flight, retried with backoff). Co-Authored-By: Claude Opus 5.5 --- scripts/format-scope.json | 15 + scripts/package-boundaries.json | 24 + src/app/layers/streetLevel.js | 7 +- src/app/layers/streetLevel.test.mjs | 11 +- src/layers/streetLevel/index.js | 7 + src/layers/streetLevel/lineCoverage.js | 926 ++++++++++++++++++ src/layers/streetLevel/policy.js | 1 + .../streetLevel/providers/kartaview/decode.js | 55 ++ .../providers/kartaview/decode.test.mjs | 75 ++ .../streetLevel/providers/kartaview/index.js | 374 +++++++ .../providers/kartaview/panorama.js | 213 ++++ .../providers/kartaview/panorama.test.mjs | 37 + .../streetLevel/providers/kartaview/policy.js | 203 ++++ .../providers/kartaview/policy.test.mjs | 146 +++ .../providers/kartaview/provider.test.mjs | 231 +++++ .../streetLevel/providers/kartaview/source.js | 226 +++++ .../providers/kartaview/source.test.mjs | 192 ++++ .../streetLevel/providers/kartaview/viewer.js | 422 ++++++++ .../providers/kartaview/viewer.test.mjs | 270 +++++ .../providers/mapillary/coverage.js | 925 +---------------- .../streetLevel/providers/mapillary/policy.js | 2 - .../providers/mapillary/sequences.js | 313 +----- src/layers/streetLevel/registry.js | 5 +- src/layers/streetLevel/selection.js | 19 +- src/layers/streetLevel/selection.test.mjs | 55 +- src/layers/streetLevel/sequenceCones.js | 331 +++++++ src/layers/streetLevel/uiState.js | 3 + src/layers/streetLevel/uiState.test.mjs | 1 + src/layers/streetLevel/viewerHost.js | 4 + src/testSupport/kartaViewFakes.mjs | 60 ++ src/tools/globePanel.js | 7 +- src/ui/streetLevelPresentation.js | 2 +- src/ui/styles/street-level.css | 80 ++ 33 files changed, 4046 insertions(+), 1196 deletions(-) create mode 100644 src/layers/streetLevel/lineCoverage.js create mode 100644 src/layers/streetLevel/providers/kartaview/decode.js create mode 100644 src/layers/streetLevel/providers/kartaview/decode.test.mjs create mode 100644 src/layers/streetLevel/providers/kartaview/index.js create mode 100644 src/layers/streetLevel/providers/kartaview/panorama.js create mode 100644 src/layers/streetLevel/providers/kartaview/panorama.test.mjs create mode 100644 src/layers/streetLevel/providers/kartaview/policy.js create mode 100644 src/layers/streetLevel/providers/kartaview/policy.test.mjs create mode 100644 src/layers/streetLevel/providers/kartaview/provider.test.mjs create mode 100644 src/layers/streetLevel/providers/kartaview/source.js create mode 100644 src/layers/streetLevel/providers/kartaview/source.test.mjs create mode 100644 src/layers/streetLevel/providers/kartaview/viewer.js create mode 100644 src/layers/streetLevel/providers/kartaview/viewer.test.mjs create mode 100644 src/layers/streetLevel/sequenceCones.js create mode 100644 src/testSupport/kartaViewFakes.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4973bd484..57fe34f7c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -907,6 +907,21 @@ "src/layers/streetLevel/providers/google/provider.test.mjs", "src/layers/streetLevel/providers/google/viewer.test.mjs", "src/layers/streetLevel/providers/google/mapsLoader.test.mjs", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", + "src/layers/streetLevel/providers/kartaview/policy.test.mjs", + "src/layers/streetLevel/providers/kartaview/decode.test.mjs", + "src/layers/streetLevel/providers/kartaview/source.test.mjs", + "src/layers/streetLevel/providers/kartaview/panorama.test.mjs", + "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", + "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", "server/providers/mapillary.js", "server/providers/mapillary/routes.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fc7308ab5..ba1f0c36a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1229,6 +1229,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "scripts/qa-street-level.mjs" ], @@ -2189,6 +2197,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", @@ -2720,6 +2736,14 @@ "src/layers/streetLevel/providers/google/policy.js", "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", + "src/layers/streetLevel/providers/kartaview/index.js", + "src/layers/streetLevel/providers/kartaview/policy.js", + "src/layers/streetLevel/providers/kartaview/source.js", + "src/layers/streetLevel/providers/kartaview/decode.js", + "src/layers/streetLevel/providers/kartaview/viewer.js", + "src/layers/streetLevel/providers/kartaview/panorama.js", + "src/layers/streetLevel/lineCoverage.js", + "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", "src/app/layers/streetLevel.js", "src/layers/alpr/tileRecords.js", diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index 350e71dea..f176d97de 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -1,6 +1,7 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; +import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as input from '../../data/inputOwnership.js'; @@ -10,12 +11,16 @@ import * as render from '../../renderGovernor.js'; /** The browser Maps key the scene was started with (src/app/scene.js). */ const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null; -/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */ +/** + * Mapillary, Google Street View and KartaView, once the Mapillary source is + * supplied; else none. + */ function defaultProviders(sources) { if (sources.mapillary == null) return []; return [ createMapillaryProvider({ source: sources.mapillary }), createGoogleProvider({ getApiKey: googleApiKey }), + createKartaViewProvider(), ]; } diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index 8087a2c38..c2c3ebb16 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it ); }); -test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => { +test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => { const { createDefaultLayerState } = await import('../../data/layerState.js'); const mapillary = { getStatus() {}, @@ -37,7 +37,7 @@ test('the defaults are Mapillary (given its source) and Street View, which start nearestImages() {}, }; const layer = createApplicationStreetLevel({ sources: { mapillary } }); - assert.deepEqual(layer.providerIds, ['mapillary', 'google']); + assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']); // A fresh page and a restored default agree: Street View is billed, so off. layer.setParams(createDefaultLayerState().options['street-level']); assert.equal(layer.getParams().google, false); @@ -45,7 +45,12 @@ test('the defaults are Mapillary (given its source) and Street View, which start // A link that lit Street View carries it in the shared switch field. layer.setParams({ providerSwitches: 'google-1' }); assert.equal(layer.getParams().google, true); - assert.equal(layer.getParams().providerSwitches, 'google-1'); + assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0'); + // KartaView is keyless and free, but one source shows at a time. + layer.setParams({ providerSwitches: 'kartaview-1' }); + assert.equal(layer.getParams().kartaview, true); + assert.equal(layer.getParams().google, false); + assert.equal(layer.getParams().mapillary, false); // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index eed4230ab..c7170f05c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -109,6 +109,12 @@ export function createStreetLevelLayer({ notify, actions: { openImage: (imageId) => openImage(entry.def.id, imageId), + /** This provider's image nearest a point the user picked on the map. */ + openNearest: (point) => + layer.openNearest(point, { + providerIds: [entry.def.id], + frame: false, + }), /** * Show a provider error, or withdraw it (null). A withdrawal clears * only this provider's own error, never a newer one from elsewhere. @@ -407,6 +413,7 @@ export function createStreetLevelLayer({ hint: hintFor(entry, stats), error: stats.error || null, color: entry.def.colors.coverage, + coverageLabel: entry.def.coverageLabel || null, groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js new file mode 100644 index 000000000..85edd97c1 --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.js @@ -0,0 +1,926 @@ +import * as Cesium from 'cesium'; +import { passesImageryFilter } from './filter.js'; +import { + createHorizonCull, + groundUnderCamera, + metresBetween, + viewFocus, + visibleBbox, + whenIdle, +} from './view.js'; +import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js'; +import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js'; +import { tileBounds, tilesForBbox } from './tileMath.js'; + +/** Draped lines stay at most this long while a tile's cast lines build. */ +export const SWAP_MAX_WAIT_MS = 4000; +/** Tiles touched by new mesh samples are redrawn at most this often. */ +const REMESH_INTERVAL_MS = 1500; +/** Gap between redrawing one dirty tile and the next. */ +const REMESH_STAGGER_MS = 120; +/** Old-zoom tiles are kept at most this long after a zoom change. */ +const STALE_TILE_MAX_MS = 6000; +/** Sequences per primitive: smaller batches build, and show, sooner. */ +const SEQUENCE_PRIMITIVE_BATCH = 120; + +/** Separates a multi-part sequence's part index from its id in pick ids. */ +const PART_SEPARATOR = '~'; + +/** Pick ids for every part of a sequence; the first part is unsuffixed. */ +function partIds(sequence, prefix) { + return sequence.parts.map((_, index) => + index + ? `${prefix}${sequence.id}${PART_SEPARATOR}${index}` + : `${prefix}${sequence.id}`, + ); +} + +/** Sequence id for any part's pick id (`[~]`), else null. */ +export function sequenceIdFromPick(pickId, prefix) { + if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix)) + return null; + const rest = pickId.slice(prefix.length); + const cut = rest.indexOf(PART_SEPARATOR); + return cut === -1 ? rest : rest.slice(0, cut); +} + +/** + * Whether any point of `box` ({west, south, east, north}) is within the mesh + * sampler's range of `centre`, the ground point under the camera. + */ +export function meshBoxInRange(box, centre) { + // Move the camera's longitude to the box's side of the date line first: + // clamping 179.999 into a box at -179.99 would pick its far edge. + const lon = + centre.lon + + 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); + const nearest = { + lon: Math.min(box.east, Math.max(box.west, lon)), + lat: Math.min(box.north, Math.max(box.south, centre.lat)), + }; + return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; +} + +/** + * @typedef {object} LineCoverageTuning + * @property {number} maxTiles Line tiles drawn at once. + * @property {number} overviewMaxTiles Overview tiles drawn at once. + * @property {number} maxSequences Lines drawn at once, over every tile. + * @property {number} lineWidthPx + * @property {number} overviewPointPx + * @property {number} moveDebounceMs Camera rest before the tile set is recomputed. + * @property {number} viewRangeMinM Street-zoom coverage reaches at least this far… + * @property {number} viewRangePerHeight …or camera height times this. + * @property {number} viewNearM Ground always covered around a tilted camera. + */ + +/** + * Camera-driven coverage from any XYZ line-tile source: overview points from + * orbit, sequence lines near the ground. In terrain surface mode a tile is + * drawn draped first, then swapped for lines cast to the bare earth (draped + * lines would land on roofs), so coverage never waits on terrain heights. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags. + * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array, overview: Array}>} options.fetchTile + * A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview + * points `{lon, lat, capturedAt, isPano}`. A rejection may carry + * `keyRequired`, `keyRejected` or `retryAfterSec`. + * @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor + * Tile zoom for a camera height above ground; null draws nothing. + * @param {{coverage: string, selected: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the lines. + * @param {string} options.renderKey Render-governor reason. + * @param {LineCoverageTuning} options.tuning + * @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages + * @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point. + */ +export function createLineCoverage({ + state, + fetchTile, + zoomFor, + colors, + pickPrefix, + renderKey, + tuning, + messages, + passes = passesImageryFilter, +}) { + const { render } = state.services; + const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles); + + /** Colour for a line: the provider's, GEV cyan while selected. */ + function sequenceColor({ selected = false } = {}) { + return selected + ? Cesium.Color.fromCssColorString(colors.selected) + : Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92); + } + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function ensureTerrainReady() { + return (state.coverage.terrainReady ||= + Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); + } + + function tileKey({ x, y, z }) { + return `${z}/${x}/${y}`; + } + + /** The imagery filter, resolved now: "since N days" moves with the clock. */ + function filter() { + return state.context.getFilter(); + } + + function terrainMode() { + return ( + state.context.getSurface?.() === 'terrain' && + Boolean(state.context.groundCaster) + ); + } + + /** + * A tile's sequences, filtered then capped: capping first could leave a + * dense tile of newer flat captures with no 360° lines at all. + */ + function drawnSequences(entry) { + const drawn = []; + const current = filter(); + for (const sequence of entry.sequenceList) { + if (drawn.length >= perTileSequenceCap) break; + if (passes(sequence, current)) drawn.push(sequence); + } + return drawn; + } + + /** + * Batched primitives for a tile's sequence parts: cast polylines where + * terrain heights are cached, draped ground lines otherwise. Each batch + * records the selection its colours were built with. + */ + function buildSequencePrimitives(sequences) { + const ground = terrainMode() ? state.context.groundCaster : null; + const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; + const selectedId = state.sequence.selectedId ?? null; + const draped = []; + const cast = []; + for (const sequence of sequences) { + const color = Cesium.ColorGeometryInstanceAttribute.fromColor( + sequenceColor({ selected: sequence.id === selectedId }), + ); + const ids = partIds(sequence, pickPrefix); + sequence.parts.forEach((coordinates, index) => { + const flat = ground?.castLine(coordinates, { meshAt }); + let positions; + try { + positions = flat + ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) + : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); + } catch { + return; + } + if (positions.length < 2) return; + const geometry = flat + ? new Cesium.PolylineGeometry({ + positions, + width: tuning.lineWidthPx, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + arcType: Cesium.ArcType.NONE, + }) + : new Cesium.GroundPolylineGeometry({ + positions, + width: tuning.lineWidthPx, + }); + (flat ? cast : draped).push( + new Cesium.GeometryInstance({ + geometry, + id: ids[index], + attributes: { color }, + }), + ); + }); + } + const primitives = []; + for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: true, + selectedId, + primitive: new Cesium.GroundPolylinePrimitive({ + geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + allowPicking: true, + }), + }); + for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push({ + onGround: false, + selectedId, + primitive: new Cesium.Primitive({ + geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + asynchronous: true, + allowPicking: true, + }), + }); + return { primitives, draped, cast: cast.length }; + } + + /** + * Fetch the terrain heights a tile's lines need, then redraw it cast. + * `castRequested` stops the redraw from casting again at once; a refresh + * `retry`s a tile whose lines are still draped (a failed or partial cast), + * and it is redrawn only when more of its lines can be cast. + */ + function castTile(entry, { retry = false } = {}) { + if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) + return; + entry.castRequested = true; + entry.castAbort = new AbortController(); + const { signal } = entry.castAbort; + const lines = [...entry.sequences.values()].flatMap( + (sequence) => sequence.parts, + ); + const caster = state.context.groundCaster; + caster.prepareLines(lines, { signal }).then(() => { + if (entry.castAbort?.signal === signal) entry.castAbort = null; + const attached = [...state.coverage.tiles.values()].includes(entry); + if (signal.aborted || !attached || !terrainMode()) return; + // Nothing new to cast: keep what is drawn until the next refresh. + // Counted like buildSequencePrimitives: a cast line needs two points. + const castable = lines.filter( + (coords) => caster.castLine(coords)?.length >= 6, + ).length; + if (castable <= (entry.castLines || 0)) { + entry.castRequested = (entry.castLines || 0) > 0; + return; + } + // A remesh may have cleared the flag while this was in flight. + entry.castRequested = true; + redrawTile(entry); + requestMesh(entry); + }); + } + + /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ + function redrawTile(entry) { + const previous = entry.primitives; + entry.primitives = []; + attachPrimitive(entry); + removeWhenReady(entry, previous); + } + + /** Lon/lat under the camera, where mesh range is measured from. */ + function meshCentre() { + const carto = state.viewer?.camera?.positionCartographic; + if (!carto) return null; + return { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + }; + } + + /** + * A tile's drawn lines with bounding boxes, built once per filter; mesh + * points are densified the first time a line is in range. + */ + function meshParts(entry) { + if (entry.meshParts) return entry.meshParts; + entry.meshParts = []; + for (const sequence of entry.sequences.values()) + for (const part of sequence.parts) { + const box = { + west: Infinity, + south: Infinity, + east: -Infinity, + north: -Infinity, + }; + for (const [lon, lat] of part) { + if (lon < box.west) box.west = lon; + if (lon > box.east) box.east = lon; + if (lat < box.south) box.south = lat; + if (lat > box.north) box.north = lat; + } + entry.meshParts.push({ part, box, points: null }); + } + return entry.meshParts; + } + + /** + * Ask for mesh samples under a cast tile's lines within the sampler's range + * of the camera; the rest are asked for when a camera move brings them in. + */ + function requestMesh(entry) { + const sampler = state.context.meshSampler; + if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; + const centre = meshCentre(); + if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) + return; + const points = []; + for (const line of meshParts(entry)) { + if (!meshBoxInRange(line.box, centre)) continue; + line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); + for (const point of line.points) points.push(point); + } + if (points.length) sampler.request(points); + } + + /** Redraw the tiles new mesh samples fall in, throttled. */ + function onMeshSampled(batch) { + if (!terrainMode() || !state.context.isActive()) return; + for (const entry of state.coverage.tiles.values()) { + if (entry.kind !== 'sequence' || !entry.bounds) continue; + const { west, south, east, north } = entry.bounds; + if ( + batch.some( + ([lon, lat]) => + lon >= west && lon <= east && lat >= south && lat <= north, + ) + ) + state.coverage.remeshDirty.add(entry); + } + if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; + const wait = Math.max( + 0, + REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), + ); + state.coverage.remeshTimer = setTimeout(remesh, wait); + } + + /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ + function remesh() { + state.coverage.remeshTimer = null; + state.coverage.remeshAt = Date.now(); + if (!terrainMode() || !state.context.isActive()) { + state.coverage.remeshDirty.clear(); + return; + } + const attached = new Set(state.coverage.tiles.values()); + const [entry] = state.coverage.remeshDirty; + if (!entry) return; + state.coverage.remeshDirty.delete(entry); + if (attached.has(entry)) { + redrawTile(entry); + requestRender(); + } + if (state.coverage.remeshDirty.size) + state.coverage.remeshTimer = setTimeout( + () => whenIdle(remesh, 500), + REMESH_STAGGER_MS, + ); + } + + state.coverage.remeshDirty = new Set(); + state.context.meshSampler?.onSampled(onMeshSampled); + + /** + * Remove a tile's previous primitives once all its current ones are ready + * (or after a few seconds); detaching the tile removes them at once. + */ + function removeWhenReady(entry, old) { + const scene = state.viewer?.scene; + finishSwap(entry); + if (!scene?.postRender) { + removePrimitives(old); + return; + } + const fresh = entry.primitives; + const started = Date.now(); + const stop = scene.postRender.addEventListener(() => { + const ready = fresh.every( + ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), + ); + if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { + requestRender(); + return; + } + finishSwap(entry); + requestRender(); + }); + entry.swap = { old, stop }; + requestRender(); + } + + function finishSwap(entry) { + if (!entry.swap) return; + entry.swap.stop(); + removePrimitives(entry.swap.old); + entry.swap = null; + } + + function buildOverviewCollection(points) { + const collection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha( + 0.85, + ); + const drawn = []; + const current = filter(); + for (const point of points) { + if (!passes(point, current)) continue; + drawn.push( + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: tint, + pixelSize: tuning.overviewPointPx, + // Google 3D terrain and clouds must not hide the near side's dots; + // the horizon cull hides the far side's, which this lets through. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }), + ); + } + return { collection, points: drawn }; + } + + /** Every overview point on the globe, current zoom and old alike. */ + function* overviewPoints() { + for (const entry of drawnEntries()) + if (entry.overviewPoints) yield* entry.overviewPoints; + } + + // Overview points skip the depth test: hide the far hemisphere's. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: overviewPoints, + onChange: requestRender, + }); + + function attachPrimitive(entry) { + const scene = state.viewer?.scene; + if (!scene) return; + if (entry.kind === 'sequence') { + // Lookup, picking and counts follow what is drawn, not the whole tile. + const sequences = drawnSequences(entry); + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + const { primitives, draped, cast } = buildSequencePrimitives(sequences); + entry.primitives = primitives; + entry.count = sequences.length; + for (const { primitive, onGround } of primitives) + (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); + // Fewer lines cast than last time: the caster has dropped heights this + // tile used (a full cache), so it must be cast again. + if (cast < (entry.castLines || 0)) entry.castRequested = false; + entry.castLines = cast; + entry.drapedLines = draped.length; + if (draped.length) castTile(entry); + watchSelection(); + } else { + const { collection, points } = buildOverviewCollection(entry.points); + entry.primitive = collection; + entry.overviewPoints = points; + entry.count = points.length; + scene.primitives.add(collection); + // Cull the new points now, then all of them whenever the camera moves. + horizon.update(points); + } + } + + function removePrimitives(list) { + const scene = state.viewer?.scene; + for (const { primitive, onGround } of list || []) { + try { + (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( + primitive, + ); + } catch { + /* already gone */ + } + } + } + + function detachPrimitive(entry) { + const scene = state.viewer?.scene; + finishSwap(entry); + removePrimitives(entry.primitives); + entry.primitives = []; + if (entry.primitive) { + try { + scene?.primitives?.remove(entry.primitive); + } catch { + /* already gone */ + } + entry.primitive = null; + entry.overviewPoints = null; + } + } + + /** Stop a tile's pending terrain lookup (tile dropped or retired). */ + function cancelCast(entry) { + entry.castAbort?.abort(); + entry.castAbort = null; + } + + function removeTile(key) { + const entry = state.coverage.tiles.get(key); + if (!entry) return; + cancelCast(entry); + detachPrimitive(entry); + state.coverage.tiles.delete(key); + } + + async function loadTile(tile, kind) { + const key = tileKey(tile); + if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) + return; + const controller = new AbortController(); + state.coverage.pending.set(key, controller); + notify(); + try { + // Fetch and the one-time terrain-height table load run side by side, + // both handled from the start so an early tile failure is not unhandled. + const [decoded] = await Promise.all([ + fetchTile(tile, { signal: controller.signal }), + kind === 'sequence' ? ensureTerrainReady() : null, + ]); + // Only an abort, a retire, a clear or a newer request for this tile + // discards the bytes; a refresh that still wants the tile keeps them. + const current = () => + state.coverage.pending.get(key) === controller && + !state.coverage.tiles.has(key); + if ( + controller.signal.aborted || + !current() || + kind !== state.coverage.kind || + tile.z !== state.coverage.zoom + ) + return; + const entry = { kind, primitive: null, primitives: [], count: 0 }; + // Padded: vector tiles carry a small buffer past their edge. + const bounds = tileBounds(tile.x, tile.y, tile.z); + const pad = (bounds.east - bounds.west) * 0.05; + entry.bounds = { + west: bounds.west - pad, + east: bounds.east + pad, + south: bounds.south - pad, + north: bounds.north + pad, + }; + if (kind === 'sequence') { + // Newest first; the per-tile cap applies to what passes the filter. + entry.sequenceList = decoded.sequences.sort( + (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), + ); + entry.sequences = new Map(); + entry.total = decoded.sequences.length; + } else { + entry.points = decoded.overview; + entry.sequences = new Map(); + entry.total = decoded.overview.length; + } + attachPrimitive(entry); + state.coverage.tiles.set(key, entry); + state.coverage.lastError = null; + requestRender(); + } catch (error) { + if ( + controller.signal.aborted || + state.coverage.pending.get(key) !== controller + ) + return; + state.coverage.lastError = error?.message || 'Coverage tile failed'; + if (error?.keyRequired) state.keyRequired = true; + if (error?.keyRejected) { + // Every other tile would be refused too: stop asking. + state.keyRejected = true; + state.coverage.lastError = + messages.keyRejected || state.coverage.lastError; + } + if (error?.retryAfterSec) holdFor(error.retryAfterSec); + } finally { + // Only the request that still owns the key settles it: a superseded one + // must not drop a newer request's entry (which is what counts as loading). + if (state.coverage.pending.get(key) === controller) { + state.coverage.pending.delete(key); + if (!state.coverage.pending.size) purgeStale(); + } + notify(); + } + } + + /** + * The source is rate-limiting: keep what is drawn, request nothing until the + * wait is over, then refresh once. + */ + function holdFor(seconds) { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdUntil = Date.now() + seconds * 1000; + state.coverage.lastError = messages.rateLimited; + state.coverage.holdTimer = setTimeout(() => { + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + if (state.coverage.lastError === messages.rateLimited) + state.coverage.lastError = null; + refresh(); + }, seconds * 1000); + } + + /** Forget refusals when the provider goes off, so the next run asks again. */ + function resetErrors() { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + state.coverage.lastError = null; + state.keyRejected = false; + } + + /** Drop the previous zoom's tiles once the new ones are on screen. */ + function purgeStale() { + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = null; + if (!state.coverage.stale.size) return; + for (const entry of state.coverage.stale.values()) detachPrimitive(entry); + state.coverage.stale.clear(); + requestRender(); + } + + /** Move tiles to the stale set: the old zoom shows until the new one loads. */ + function retire() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const [key, entry] of state.coverage.tiles) { + cancelCast(entry); + const previous = state.coverage.stale.get(key); + if (previous) detachPrimitive(previous); + state.coverage.stale.set(key, entry); + } + state.coverage.tiles.clear(); + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + } + + function notify() { + state.context.notify(); + } + + /** + * Whether the screen centre meets the ground further ahead than the camera + * is high (a view tilted above 45°), or misses it. + */ + function isTilted({ nadir, ahead }, height) { + if (!ahead) return true; + return metresBetween(nadir, ahead) > Math.max(1, height); + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !state.context.isActive() || state.keyRequired) return; + if (state.statusKnown === false) return; + if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; + const ground = groundUnderCamera(viewer, { + groundAt: state.context.groundCaster?.groundAt, + }); + const cameraHeight = viewer.camera?.positionCartographic?.height; + const height = Number.isFinite(cameraHeight) + ? cameraHeight - (ground ?? 0) + : null; + const level = zoomFor(height); + const zoom = level?.zoom ?? null; + const kind = level?.kind ?? null; + // At street zooms the rays meet the ground where it really is (1,600 m up + // in Denver; Google 3D hides the globe) and stop short of the horizon. + const ranged = + kind === 'sequence' + ? { + groundHeight: ground, + maxRange: Math.max( + tuning.viewRangeMinM, + height * tuning.viewRangePerHeight, + ), + } + : {}; + // Tilted street views rank tiles along the line of sight, from the ground + // under the camera to the ground at the centre of the screen. + const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; + let bbox = visibleBbox(viewer, { + ...ranged, + // A tilted view's screen rows jump from the first metres to the horizon: + // keep the ground around the camera too. Looking down needs no help. + nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null, + }); + if (kind === 'overview' && (!bbox || zoom <= 1)) + bbox = [-180, -85, 180, 85]; + if (zoom == null || !bbox) { + state.coverage.hint = + zoom == null && Number.isFinite(height) && messages.tooHigh + ? messages.tooHigh + : 'Point the camera at the globe for street-level coverage'; + clear(); + notify(); + return; + } + state.coverage.hint = ''; + if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + retire(); + state.coverage.zoom = zoom; + state.coverage.kind = kind; + } + const { tiles } = tilesForBbox(bbox, zoom, { + limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles, + focus: focus && { from: focus.nadir, to: focus.ahead }, + }); + const wanted = new Set(tiles.map(tileKey)); + for (const key of [...state.coverage.tiles.keys()]) + if (!wanted.has(key)) removeTile(key); + for (const [key, controller] of [...state.coverage.pending]) + if (!wanted.has(key)) { + // The aborted request no longer owns its key, so its `finally` will + // not settle it: settle it here. + controller.abort(); + state.coverage.pending.delete(key); + } + for (const tile of tiles) loadTile(tile, kind); + if (!state.coverage.pending.size) purgeStale(); + // The camera moved: cells that were out of the sampler's range may not be, + // and lines left draped by a failed, partial or dropped cast get another try. + if (terrainMode()) + for (const entry of state.coverage.tiles.values()) { + if (entry.castRequested) requestMesh(entry); + if (entry.drapedLines) castTile(entry, { retry: true }); + } + notify(); + } + + function scheduleRefresh() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs); + } + + function attach(viewer) { + detach(); + const remove = viewer.camera.changed.addEventListener(scheduleRefresh); + const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + state.coverage.removeCameraListener = () => { + remove(); + removeEnd(); + }; + refresh(); + } + + function detach() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = null; + state.coverage.removeCameraListener?.(); + state.coverage.removeCameraListener = null; + } + + function clear() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + for (const key of [...state.coverage.tiles.keys()]) removeTile(key); + purgeStale(); + state.coverage.zoom = null; + state.coverage.kind = null; + clearTimeout(state.coverage.remeshTimer); + state.coverage.remeshTimer = null; + state.coverage.remeshDirty.clear(); + stopSelectionWatch(); + horizon.stop(); + requestRender(); + } + + /** Rebuild every loaded tile from its decoded cache (after a filter change). */ + function rebuild() { + purgeStale(); + for (const entry of state.coverage.tiles.values()) { + cancelCast(entry); + entry.castRequested = false; + entry.meshParts = null; + detachPrimitive(entry); + attachPrimitive(entry); + } + requestRender(); + notify(); + } + + /** + * Redraw tiles draped or cast for the new surface mode, then re-pick the + * zoom, since the ground height under the camera may have changed. + */ + function setSurface() { + if (!state.context.isActive()) return; + rebuild(); + refresh(); + } + + /** Recolour every part of a sequence in one ready primitive. */ + function recolorInstances(primitive, sequence, selected) { + const value = sequenceColor({ selected }); + for (const instanceId of partIds(sequence, pickPrefix)) { + try { + const attributes = primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + value, + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + + /** Every tile on the globe: the current zoom's and the stale zoom's. */ + function drawnEntries() { + return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; + } + + /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ + function drawnRecords(entry) { + const records = entry.primitives || []; + return entry.swap ? [...records, ...entry.swap.old] : records; + } + + /** Recolour one sequence, every part of it, in place (selection highlight). */ + function recolorSequence(id, selected) { + for (const entry of drawnEntries()) { + const sequence = entry.sequences.get(id); + if (!sequence) continue; + for (const record of drawnRecords(entry)) { + // Still building: `syncSelection` catches it up once it is ready. + if (!record.primitive.ready) continue; + recolorInstances(record.primitive, sequence, selected); + if (selected) record.selectedId = id; + else if (record.selectedId === id) record.selectedId = null; + } + } + requestRender(); + } + + /** + * Bring a ready primitive's highlight up to the current selection, when + * it was built with another one (or none). True when it changed. + */ + function syncSelection(entry, record) { + const current = state.sequence.selectedId ?? null; + if (record.selectedId === current || !record.primitive.ready) return false; + const previous = + record.selectedId && entry.sequences.get(record.selectedId); + if (previous) recolorInstances(record.primitive, previous, false); + const next = current && entry.sequences.get(current); + if (next) recolorInstances(record.primitive, next, true); + record.selectedId = current; + return true; + } + + /** + * A primitive cannot be recoloured until it is ready, so while any is + * building, catch each one up with the selection as it becomes ready. + */ + function watchSelection() { + const scene = state.viewer?.scene; + if (state.coverage.stopSelectionWatch || !scene?.postRender) return; + const stop = scene.postRender.addEventListener(() => { + let building = false; + let changed = false; + for (const entry of drawnEntries()) { + for (const record of entry.primitives || []) { + if (!record.primitive.ready) building = true; + else if (syncSelection(entry, record)) changed = true; + } + // Lines a swap still shows were ready (or never show): catch up only. + for (const record of entry.swap?.old || []) + if (syncSelection(entry, record)) changed = true; + } + if (changed) requestRender(); + if (!building) stopSelectionWatch(); + }); + state.coverage.stopSelectionWatch = stop; + } + + function stopSelectionWatch() { + state.coverage.stopSelectionWatch?.(); + state.coverage.stopSelectionWatch = null; + } + + /** + * Sequences (or overview points) drawn. A sequence that crosses a tile + * edge is in both tiles' lists, so sequences count once by id. + */ + function sequenceCount() { + const ids = new Set(); + let points = 0; + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') + for (const id of entry.sequences.keys()) ids.add(id); + else points += entry.count; + return ids.size + points; + } + + return { + attach, + detach, + refresh, + clear, + resetErrors, + rebuild, + setSurface, + recolorSequence, + sequenceCount, + }; +} diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 6f310274f..84f9e2fc7 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -8,6 +8,7 @@ export const POSITION_PICK_ID = 'sl:pos'; export const PROVIDER_COLORS = Object.freeze({ mapillary: '#05cb63', google: '#4285f4', + kartaview: '#635bff', }); /** Colours every provider shares: the selection highlight and the marker. */ diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js new file mode 100644 index 000000000..2c8687eba --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -0,0 +1,55 @@ +/** + * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into + * line records for the shared coverage. The tile's lines are OSM way + * segments with photos; segments that meet end to start are chained, which + * cuts the line count about fourfold. Lines carry no sequence, date or + * projection, so nothing can filter them. + * @param {object} payload + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +export function decodeCoverageTile(payload, { x, y, z }) { + const segments = []; + for (const line of Array.isArray(payload?.lines) ? payload.lines : []) { + const points = []; + for (const point of Array.isArray(line?.track) ? line.track : []) { + const lon = Number(point?.lng); + const lat = Number(point?.lat); + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + const last = points[points.length - 1]; + if (last && last[0] === lon && last[1] === lat) continue; + points.push([lon, lat]); + } + if (points.length >= 2) segments.push(points); + } + const key = ([lon, lat]) => `${lon},${lat}`; + const startingAt = new Map(); + segments.forEach((segment, index) => { + const at = key(segment[0]); + const list = startingAt.get(at); + if (list) list.push(index); + else startingAt.set(at, [index]); + }); + const used = new Uint8Array(segments.length); + const sequences = []; + segments.forEach((segment, index) => { + if (used[index]) return; + used[index] = 1; + const chain = [...segment]; + for (;;) { + const next = startingAt + .get(key(chain[chain.length - 1])) + ?.find((candidate) => !used[candidate]); + if (next === undefined) break; + used[next] = 1; + chain.push(...segments[next].slice(1)); + } + sequences.push({ + id: `${z}/${x}/${y}/${sequences.length}`, + parts: [chain], + capturedAt: 0, + isPano: false, + }); + }); + return { sequences, overview: [] }; +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs new file mode 100644 index 000000000..c1ab57f3a --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeCoverageTile } from './decode.js'; + +const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); +const TILE = { x: 4412, y: 6061, z: 14 }; + +test('segments that meet end to start chain into one line', () => { + const { sequences, overview } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(2, 1)], matched: '1' }, + { track: [at(5, 5), at(6, 6)], matched: '1' }, + { track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' }, + ], + }, + TILE, + ); + assert.deepEqual(overview, []); + assert.equal(sequences.length, 2); + assert.deepEqual(sequences[0].parts, [ + [ + [1, 1], + [2, 1], + [3, 1], + [4, 2], + ], + ]); + assert.equal(sequences[0].id, '14/4412/6061/0'); + assert.equal(sequences[1].id, '14/4412/6061/1'); + // Nothing to date or project: every line passes as flat and undated. + assert.equal(sequences[0].capturedAt, 0); + assert.equal(sequences[0].isPano, false); +}); + +test('a junction continues one way only, and every segment is drawn exactly once', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(0, 0), at(1, 0)] }, + { track: [at(1, 0), at(2, 0)] }, + { track: [at(1, 0), at(1, 1)] }, + // A loop back to its own start must end, not spin. + { track: [at(9, 9), at(9, 8)] }, + { track: [at(9, 8), at(9, 9)] }, + ], + }, + TILE, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.equal(sequences.length, 3); + // 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points. + assert.equal(points, 8); +}); + +test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => { + const { sequences } = decodeCoverageTile( + { + lines: [ + { track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] }, + { track: [at(3, 3)] }, + { track: null }, + null, + ], + }, + TILE, + ); + assert.equal(sequences.length, 1); + assert.deepEqual(sequences[0].parts[0], [ + [1, 1], + [2, 2], + ]); + assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []); + assert.deepEqual(decodeCoverageTile(null, TILE).sequences, []); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js new file mode 100644 index 000000000..c9da4f7c5 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -0,0 +1,374 @@ +import { createLineCoverage } from '../../lineCoverage.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { metresBetween } from '../../view.js'; +import { decodeCoverageTile } from './decode.js'; +import { createKartaViewSource } from './source.js'; +import { createKartaViewViewer } from './viewer.js'; +import { + COLORS, + COVERAGE_MAX_HEIGHT_M, + COVERAGE_NOUN, + COVERAGE_STREET_ZOOM_BELOW_M, + COVERAGE_TUNING, + KARTAVIEW_CREDIT_HTML, + KARTAVIEW_LABEL, + KARTAVIEW_NAME, + KARTAVIEW_PROVIDER_ID, + LINE_CLICK_HINT, + NEAREST_RADIUS_M, + PICK_PREFIX, + RATE_LIMITED_MESSAGE, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, + normalizePhoto, +} from './policy.js'; + +/** Photos kept in memory by id (sequence pages fill it). */ +const PHOTO_CACHE_SIZE = 4000; +/** Sequence pages kept in memory. */ +const PAGE_CACHE_SIZE = 8; + +/** z14 near the street, z13 a little higher, nothing above. */ +export function kartaViewZoomFor(heightM) { + if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null; + return { + zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14, + kind: 'sequence', + }; +} + +/** Least-recently-used map: reading an entry renews it. */ +function createLru(limit) { + const map = new Map(); + return { + get(key) { + if (!map.has(key)) return undefined; + const value = map.get(key); + map.delete(key); + map.set(key, value); + return value; + }, + set(key, value) { + map.delete(key); + map.set(key, value); + while (map.size > limit) map.delete(map.keys().next().value); + }, + delete: (key) => map.delete(key), + clear: () => map.clear(), + }; +} + +/** The 1-based page of a sequence that holds `index`, if no photo is missing. */ +function pageOf(index) { + return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1; +} + +/** + * KartaView Street Level provider: keyless coverage lines from KartaView's + * tile API, the photos nearest a clicked line, cones along the photo's + * sequence, and a flat or 360° viewer with ‹ › along the sequence. + * @param {{source?: ReturnType, viewerOptions?: object}} [options] + * @returns {import('../../registry.js').StreetLevelProvider} + */ +export function createKartaViewProvider({ + source = createKartaViewSource(), + viewerOptions = {}, +} = {}) { + return Object.freeze({ + id: KARTAVIEW_PROVIDER_ID, + name: KARTAVIEW_NAME, + label: KARTAVIEW_LABEL, + requiresKeyId: null, + pickPrefix: PICK_PREFIX.root, + colors: COLORS, + credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), + defaultOn: false, + // Coverage lines carry no date or projection: no filter could thin them. + filters: [], + coverageLabel: Object.freeze({ + noun: COVERAGE_NOUN, + action: LINE_CLICK_HINT, + }), + + create(context) { + const state = { + context, + services: context.services, + viewer: null, + keyRequired: false, + keyRejected: false, + statusKnown: true, + coverage: { + zoom: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + kind: null, + }, + sequence: { + selectedId: null, + images: [], + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + }; + const photos = createLru(PHOTO_CACHE_SIZE); + const pages = createLru(PAGE_CACHE_SIZE); + /** The photo last opened in each sequence: its cones centre on it. */ + const anchors = new Map(); + /** The sequence page the cones were drawn from. */ + let drawnPage = null; + + function remember(record) { + const photo = normalizePhoto(record); + if (photo) photos.set(photo.id, photo); + return photo; + } + + async function getPhoto(photoId, { signal } = {}) { + const cached = photos.get(photoId); + if (cached) return cached; + const photo = remember(await source.getPhoto(photoId, { signal })); + if (!photo) throw new Error('KartaView has no position for this photo'); + return photo; + } + + /** A page of a sequence's photos, in index order (shared while in flight). */ + function loadPage(sequenceId, page) { + const key = `${sequenceId}:${page}`; + let entry = pages.get(key); + if (!entry) { + entry = source + .getSequencePhotos(sequenceId, { + page, + itemsPerPage: SEQUENCE_PAGE_SIZE, + }) + .then(({ photos: records, hasMore }) => ({ + page, + hasMore, + photos: records + .map(remember) + .filter(Boolean) + .sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + })); + pages.set(key, entry); + entry.catch(() => pages.delete(key)); + } + return entry; + } + + /** The page holding `photo` (an earlier one when photos are missing). */ + async function windowOf(photo) { + let page = pageOf(photo.sequenceIndex ?? 0); + for (let tries = 0; tries < 3; tries++) { + const window = await loadPage(photo.sequenceId, page); + const first = window.photos[0]; + if ( + window.photos.some((p) => p.id === photo.id) || + page === 1 || + !first || + (first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0) + ) + return window; + page--; + } + return loadPage(photo.sequenceId, page); + } + + async function step(photo, delta) { + if (!photo?.sequenceId) return null; + const window = await windowOf(photo); + const list = window.photos; + let at = list.findIndex((p) => p.id === photo.id); + if (at === -1) + at = list.findIndex( + (p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0), + ); + const next = at === -1 ? -1 : at + delta; + if (next >= 0 && next < list.length) return list[next].id; + if (next < 0 && window.page > 1) { + const before = await loadPage(photo.sequenceId, window.page - 1); + return before.photos.at(-1)?.id ?? null; + } + if (next >= list.length && window.hasMore) { + const after = await loadPage(photo.sequenceId, window.page + 1); + return after.photos[0]?.id ?? null; + } + return null; + } + + const coverage = createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile(await source.getTile(tile, { signal }), tile), + zoomFor: kartaViewZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.line, + renderKey: 'kartaview-coverage', + tuning: COVERAGE_TUNING, + messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT }, + passes: () => true, + }); + + const cones = createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const anchor = anchors.get(sequenceId); + if (!anchor) return []; + const window = await windowOf(anchor); + signal?.throwIfAborted(); + drawnPage = { sequenceId, page: window.page }; + return thinImages( + window.photos.map((photo) => ({ + id: photo.id, + lon: photo.lon, + lat: photo.lat, + compassAngle: photo.heading ?? 0, + capturedAt: photo.capturedAt ?? 0, + isPano: photo.isPano, + })), + ); + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:kartaview-cones', + renderKey: 'kartaview-sequence', + }); + + /** Re-centre the cones when ‹ › walked off the page they show. */ + async function followStep(photoId) { + const photo = photos.get(photoId); + if (!photo?.sequenceId) return; + anchors.set(photo.sequenceId, photo); + if (drawnPage?.sequenceId !== photo.sequenceId) return; + const window = await windowOf(photo).catch(() => null); + if (window && window.page !== drawnPage.page) + cones.select(photo.sequenceId, { force: true }); + } + + const viewer = createKartaViewViewer({ + getPhoto: async (photoId) => { + const photo = await getPhoto(photoId); + if (photo.sequenceId) anchors.set(photo.sequenceId, photo); + return photo; + }, + step, + onStepped: followStep, + render: context.services?.render, + ...viewerOptions, + }); + + return { + async status() { + return { configured: true }; + }, + + init(cesiumViewer) { + state.viewer = cesiumViewer; + cones.ensureCollections(cesiumViewer); + cones.setVisible(false); + }, + + activate(cesiumViewer) { + state.viewer = cesiumViewer; + cones.setVisible(true); + coverage.attach(cesiumViewer); + }, + + deactivate() { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.clearSelection(); + cones.setVisible(false); + drawnPage = null; + }, + + destroy(cesiumViewer) { + coverage.detach(); + coverage.clear(); + coverage.resetErrors(); + cones.destroy(cesiumViewer); + }, + + refreshCoverage: () => coverage.refresh(), + + /** No filter applies (see `filters`). */ + setFilter() {}, + + setSurface() { + coverage.setSurface(); + cones.rerender(); + }, + + coverageStats() { + const { hint } = state.coverage; + const count = coverage.sequenceCount(); + return { + count, + zoom: state.coverage.zoom, + kind: state.coverage.kind, + loading: state.coverage.pending.size > 0, + hint: hint || '', + error: state.coverage.lastError, + keyRequired: false, + keyRejected: false, + }; + }, + + /** A line opens the photo nearest the click; a cone opens its photo. */ + handlePick(pickId, { groundPoint } = {}) { + if (pickId.startsWith(PICK_PREFIX.image)) { + context.actions.openImage(pickId.slice(PICK_PREFIX.image.length)); + return true; + } + if (pickId.startsWith(PICK_PREFIX.line)) { + const point = groundPoint?.(); + if (point) context.actions.openNearest(point); + return true; + } + return false; + }, + + selectSequence: (sequenceId) => cones.select(sequenceId), + clearSequence: () => { + cones.clearSelection(); + drawnPage = null; + }, + sequenceStats: () => ({ + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }), + + /** The nearest photo within the search radius, or null. */ + async nearestImage({ lat, lon }, { signal } = {}) { + const records = await source.nearestPhotos({ lat, lon }, { signal }); + let best = null; + for (const record of records) { + const photo = remember(record); + if (!photo) continue; + const metres = metresBetween({ lat, lon }, photo); + if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres)) + best = { photo, metres }; + } + return best ? best.photo.id : null; + }, + + viewer, + }; + }, + }); +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/providers/kartaview/panorama.js new file mode 100644 index 000000000..a081d1e5e --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.js @@ -0,0 +1,213 @@ +/** + * A minimal equirectangular panorama renderer on WebGL: one full-screen + * triangle whose fragment shader turns each pixel's view ray into texture + * coordinates. The image centre is the camera's forward direction; yaw turns + * right from it, pitch looks up. + */ + +export const PANO_FOV_DEFAULT_DEG = 80; +export const PANO_FOV_MIN_DEG = 30; +export const PANO_FOV_MAX_DEG = 100; +export const PANO_PITCH_LIMIT_DEG = 85; + +const VERTEX = ` +attribute vec2 a_position; +varying vec2 v_ndc; +void main() { + v_ndc = a_position; + gl_Position = vec4(a_position, 0.0, 1.0); +}`; + +const FRAGMENT = ` +precision highp float; +uniform sampler2D u_image; +uniform float u_yaw; +uniform float u_pitch; +uniform float u_tanHalfFov; +uniform float u_aspect; +varying vec2 v_ndc; +const float PI = 3.141592653589793; +void main() { + vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0)); + float cp = cos(u_pitch), sp = sin(u_pitch); + ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp); + float cy = cos(u_yaw), sy = sin(u_yaw); + ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy); + float lon = atan(ray.x, -ray.z); + float lat = asin(clamp(ray.y, -1.0, 1.0)); + gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI)); +}`; + +/** Wrap degrees into [0, 360). */ +export function wrapDegrees(value) { + return ((value % 360) + 360) % 360; +} + +export function clampPitch(degrees) { + return Math.max( + -PANO_PITCH_LIMIT_DEG, + Math.min(PANO_PITCH_LIMIT_DEG, degrees), + ); +} + +export function clampFov(degrees) { + return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees)); +} + +/** + * Texture coordinates the shader samples for a view ray, for tests and + * picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up, + * z backward). + */ +export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) { + const p = (pitch * Math.PI) / 180; + const w = (yaw * Math.PI) / 180; + const length = Math.hypot(ray[0], ray[1], ray[2]) || 1; + let [x, y, z] = ray.map((v) => v / length); + [y, z] = [ + y * Math.cos(p) - z * Math.sin(p), + y * Math.sin(p) + z * Math.cos(p), + ]; + [x, z] = [ + x * Math.cos(w) - z * Math.sin(w), + x * Math.sin(w) + z * Math.cos(w), + ]; + const lon = Math.atan2(x, -z); + const lat = Math.asin(Math.max(-1, Math.min(1, y))); + return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI]; +} + +function compile(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Panorama shader failed: ${log}`); + } + return shader; +} + +const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0; + +/** + * A panorama view on `canvas`. Throws when WebGL is unavailable, so the + * caller can fall back to a flat picture. + * @param {HTMLCanvasElement} canvas + * @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}} + */ +export function createPanoramaRenderer(canvas) { + const gl = + canvas.getContext('webgl', { + antialias: false, + depth: false, + preserveDrawingBuffer: false, + }) || canvas.getContext('experimental-webgl'); + if (!gl) throw new Error('WebGL is unavailable'); + + const program = gl.createProgram(); + gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX)); + gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) + throw new Error('Panorama program failed to link'); + gl.useProgram(program); + + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + // One triangle covering the screen. + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + gl.STATIC_DRAW, + ); + const position = gl.getAttribLocation(program, 'a_position'); + gl.enableVertexAttribArray(position); + gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); + const uniforms = Object.fromEntries( + ['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [ + name, + gl.getUniformLocation(program, name), + ]), + ); + const texture = gl.createTexture(); + let loaded = false; + let frame = 0; + const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }; + + function resize() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.round(canvas.clientWidth * ratio)); + const height = Math.max(1, Math.round(canvas.clientHeight * ratio)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + scheduleRender(); + } + + function draw() { + frame = 0; + if (gl.isContextLost()) return; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + if (!loaded) return; + gl.uniform1i(uniforms.u_image, 0); + gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180); + gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180); + gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360)); + gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + function scheduleRender() { + if (frame) return; + frame = requestAnimationFrame(draw); + } + + return { + setImage(image) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); + // Linear only: a mipmapped lookup draws a seam where longitude wraps. + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + const repeat = + isPowerOfTwo(image.width) && isPowerOfTwo(image.height) + ? gl.REPEAT + : gl.CLAMP_TO_EDGE; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + loaded = true; + resize(); + scheduleRender(); + }, + setView(next = {}) { + if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw); + if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch); + if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov); + scheduleRender(); + }, + getView: () => ({ ...view }), + resize, + render: scheduleRender, + hasImage: () => loaded, + clear() { + loaded = false; + scheduleRender(); + }, + destroy() { + if (frame) cancelAnimationFrame(frame); + frame = 0; + loaded = false; + gl.deleteTexture(texture); + gl.deleteBuffer(buffer); + gl.deleteProgram(program); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs new file mode 100644 index 000000000..eddf7de4b --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + PANO_FOV_MAX_DEG, + PANO_FOV_MIN_DEG, + PANO_PITCH_LIMIT_DEG, + clampFov, + clampPitch, + rayToUv, + wrapDegrees, +} from './panorama.js'; + +const close = (actual, expected) => + actual.forEach((value, i) => + assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`), + ); + +test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => { + close(rayToUv([0, 0, -1]), [0.5, 0.5]); + close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]); + close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]); + close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]); + close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]); + // Pitch first, then yaw: looking up-right stays up. + close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]); + // A ray to the right of the screen is the same as turning right. + close(rayToUv([1, 0, 0]), [0.75, 0.5]); +}); + +test('view limits: pitch stops short of the poles, field of view stays usable', () => { + assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG); + assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG); + assert.equal(clampFov(5), PANO_FOV_MIN_DEG); + assert.equal(clampFov(170), PANO_FOV_MAX_DEG); + assert.equal(wrapDegrees(-30), 330); + assert.equal(wrapDegrees(725), 5); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js new file mode 100644 index 000000000..84a36d7d0 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -0,0 +1,203 @@ +import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; + +export { NEAREST_RADIUS_M } from '../../policy.js'; + +/** Identity and tuning for the KartaView street-level provider (keyless). */ +export const KARTAVIEW_PROVIDER_ID = 'kartaview'; +export const KARTAVIEW_NAME = 'KartaView'; +export const KARTAVIEW_LABEL = 'KARTAVIEW'; + +/** + * The public API. Its documented twin, api.openstreetcam.org, still answers + * but serves empty coverage tiles. + */ +export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; +/** KartaView's image proxy: resizes a photo given its base64url address. */ +export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; + +/** Stable id prefixes for picked primitives; every one starts with `kv:`. */ +export const PICK_PREFIX = Object.freeze({ + root: 'kv:', + line: 'kv:line:', + image: 'kv:img:', +}); + +export const COLORS = Object.freeze({ + coverage: PROVIDER_COLORS.kartaview, + selected: SHARED_COLORS.selected, +}); + +/** + * Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower + * zooms take seconds per tile (z10 does not answer), so higher up the panel + * asks the user to zoom in. + */ +export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500; +export const COVERAGE_MAX_HEIGHT_M = 6_000; +export const COVERAGE_TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + // Chained ways are short: a dense z13 tile holds ~1,300. + maxSequences: 9_000, + lineWidthPx: 2.5, + overviewPointPx: 2.5, + moveDebounceMs: 320, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); +export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage'; +export const RATE_LIMITED_MESSAGE = + 'KartaView is rate-limiting requests — they resume on their own'; + +/** + * The photo API answers 429 to more than about three requests at once, so + * at most two are in flight; a 429 is retried after a short wait. + */ +export const PHOTO_API_CONCURRENCY = 2; +export const PHOTO_API_RETRIES = 3; +export const PHOTO_API_RETRY_BASE_MS = 800; +export const TILE_TIMEOUT_MS = 20_000; +export const PHOTO_API_TIMEOUT_MS = 15_000; +/** A photo download (tens of kilobytes to a few megabytes). */ +export const IMAGE_TIMEOUT_MS = 30_000; + +/** Photos asked for around a point; the API returns them nearest first. */ +export const NEAREST_LIMIT = 10; +/** The photo API's search zoom: 18 searches the few tens of metres around the point. */ +export const NEAREST_SEARCH_ZOOM = 18; + +/** + * Photos of a sequence fetched at once, around the open one. Records are + * ~2 kB each, so a page is ~2 MB; the cones and prev/next use it. + */ +export const SEQUENCE_PAGE_SIZE = 1000; + +/** Equirectangular panorama sizes: a quick preview, then the sharp one. */ +export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); +export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); +/** A flat photo's sharp version, after the 1280 px one is on screen. */ +export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); + +/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */ +export const KARTAVIEW_CREDIT_HTML = + 'Street Level: imagery © KartaView contributors, CC BY-SA 4.0'; + +/** The cone and coverage noun: a line is a street with photos, not a sequence. */ +export const COVERAGE_NOUN = 'streets'; +export const LINE_CLICK_HINT = 'click a line for its nearest photo'; + +/** Base64url of a string, as the image proxy reads addresses. */ +function base64Url(text) { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** A photo resized by KartaView's image proxy to fit `width`×`height`. */ +export function resizedImageUrl(url, [width, height]) { + if (!url) return null; + return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`; +} + +/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */ +export function kartaViewTimeMs(value) { + const match = + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec( + String(value ?? ''), + ); + if (!match) return null; + const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; + if (Number(y) < 1990) return null; + const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + return Number.isFinite(ms) ? ms : null; +} + +/** A coordinate pair as numbers; the road-matched one when KartaView has it. */ +function positionOf(record) { + const matchLat = Number(record.matchLat); + const matchLon = Number(record.matchLng); + if ( + Number.isFinite(matchLat) && + Number.isFinite(matchLon) && + (matchLat !== 0 || matchLon !== 0) + ) + return { lat: matchLat, lon: matchLon }; + return { lat: Number(record.lat), lon: Number(record.lng) }; +} + +const STORAGE_HOST = /^storage\d+$/; + +/** + * A photo file's address. The API's `fileurl*` fields always name storage1, + * but a photo whose `storage` is another host (storage2…) is only there, so + * the address is built from `storage` and the relative `filepath*`. + */ +function fileUrl(record, pathKey, urlKey) { + const path = record[pathKey]; + if (STORAGE_HOST.test(record.storage || '') && path) + return `https://${record.storage}.openstreetcam.org/files/photo/${path}`; + return record[urlKey] || null; +} + +/** A storage URL with its `{{sizeprefix}}` placeholder filled. */ +function sized(record, prefix) { + const url = fileUrl(record, 'filepath', 'fileurl') || ''; + return url.includes('{{sizeprefix}}') + ? url.replace('{{sizeprefix}}', prefix) + : null; +} + +/** + * A photo API record in the shape the provider uses, or null without an id + * or a position. + * @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null} + */ +export function normalizePhoto(record) { + if (!record || record.id == null) return null; + const { lat, lon } = positionOf(record); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const heading = + record.heading === '' || record.heading == null + ? NaN + : Number(record.heading); + const isPano = + record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360; + const full = + fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc'); + const index = Number(record.sequenceIndex); + return { + id: String(record.id), + sequenceId: record.sequenceId != null ? String(record.sequenceId) : null, + sequenceIndex: Number.isFinite(index) ? index : null, + lon, + lat, + heading: Number.isFinite(heading) ? heading : null, + isPano, + capturedAt: + kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + urls: { + // A 1280 px rendition; for a 360° photo it is the front view only. + view: + fileUrl(record, 'filepathLTh', 'fileurlLTh') || + sized(record, 'lth') || + full, + full, + // A 360° photo's equirectangular image ('wrapped_proc'). + pano: isPano && /wrapped/.test(full || '') ? full : null, + }, + }; +} + +/** + * Where the image link goes. KartaView's photo pages fail for signed-out + * visitors ("Sequence cannot be loaded"), so it is the full-size photo on + * KartaView's own storage. + */ +export function kartaViewPhotoUrl(photo) { + return photo?.urls?.full || photo?.urls?.view || 'https://kartaview.org'; +} diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs new file mode 100644 index 000000000..b48e1742f --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + kartaViewPhotoUrl, + kartaViewTimeMs, + normalizePhoto, + resizedImageUrl, +} from './policy.js'; + +/** A photo record as /2.0/photo returns it (trimmed to what is read). */ +function record(overrides = {}) { + return { + id: '7102491', + sequenceId: '14010', + sequenceIndex: '950', + lat: '42.335712', + lng: '-83.048301', + matchLat: '42.335731506347656', + matchLng: '-83.04828643798828', + heading: '299.58', + fieldOfView: '0', + projection: 'PLANE', + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2016-08-24 04:01:07', + storage: 'storage1', + filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + fileurl: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg', + fileurlLTh: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + fileurlProc: + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ...overrides, + }; +} + +test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => { + const photo = normalizePhoto(record()); + assert.equal(photo.id, '7102491'); + assert.equal(photo.sequenceId, '14010'); + assert.equal(photo.sequenceIndex, 950); + assert.equal(photo.lat, 42.335731506347656); + assert.equal(photo.lon, -83.04828643798828); + assert.equal(photo.heading, 299.58); + assert.equal(photo.isPano, false); + assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47)); + assert.equal(photo.urls.pano, null); +}); + +test('photo files come from the storage host the record names, not the one its URLs say', () => { + // The API writes storage1 into every URL; this photo lives on storage2. + const photo = normalizePhoto(record({ storage: 'storage2' })); + assert.equal( + photo.urls.view, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); + assert.equal( + photo.urls.full, + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + assert.equal(kartaViewPhotoUrl(photo), photo.urls.full); + // An unexpected storage value falls back to the URLs as given. + const odd = normalizePhoto(record({ storage: 'https://evil.example' })); + assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//); + // No large thumbnail path: the size prefix fills in. + const bare = normalizePhoto( + record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }), + ); + assert.equal( + bare.urls.view, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + ); +}); + +test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => { + const photo = normalizePhoto( + record({ + projection: 'SPHERE', + fieldOfView: '360', + matchLat: '0', + matchLng: '0', + storage: 'storage13', + filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg', + }), + ); + assert.equal(photo.isPano, true); + assert.equal(photo.lat, 42.335712); + assert.equal( + photo.urls.pano, + 'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg', + ); + // A sphere without a wrapped image has nothing to project. + assert.equal( + normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls + .pano, + null, + ); +}); + +test('records without an id or a position are dropped; a missing heading is null', () => { + assert.equal(normalizePhoto(null), null); + assert.equal(normalizePhoto(record({ id: undefined })), null); + assert.equal( + normalizePhoto( + record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }), + ), + null, + ); + assert.equal(normalizePhoto(record({ heading: '' })).heading, null); + assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0); + assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null); +}); + +test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => { + assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21)); + assert.equal( + kartaViewTimeMs('2022-03-21T06:42:59'), + Date.UTC(2022, 2, 21, 6, 42, 59), + ); + assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); + assert.equal(kartaViewTimeMs(''), null); + assert.equal(kartaViewTimeMs(null), null); + const fallback = normalizePhoto(record({ shotDate: null })); + assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7)); +}); + +test('resized images go through the proxy with a base64url address', () => { + const url = resizedImageUrl( + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + [1024, 512], + ); + const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url); + assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/); + assert.doesNotMatch(encoded, /[+/=]/); + const decoded = Buffer.from( + encoded.replace(/-/g, '+').replace(/_/g, '/'), + 'base64', + ).toString(); + assert.equal( + decoded, + 'https://storage13.openstreetcam.org/files/photo/a?b=c+d', + ); + assert.equal(resizedImageUrl(null, [1, 1]), null); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs new file mode 100644 index 000000000..ef626fe09 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createKartaViewProvider, kartaViewZoomFor } from './index.js'; +import { SEQUENCE_PAGE_SIZE } from './policy.js'; +import { validateProviders } from '../../registry.js'; +import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; + +/** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */ +function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { + const lon = -83.0458 + metres / 82_300; + return { + id: String(id), + sequenceId: seq, + sequenceIndex: String(index), + lat: '42.3314', + lng: String(lon), + heading: '90', + projection: 'PLANE', + fieldOfView: '0', + shotDate: '2016-08-23 20:17:47.000', + storage: 'storage2', + filepathLTh: `2016/8/24/lth/${id}.jpg`, + filepathProc: `2016/8/24/proc/${id}.jpg`, + }; +} + +/** A source over `sequences` (id → indexes present), recording calls. */ +function fakeSource({ nearest = [], sequences = {} } = {}) { + const calls = { nearest: 0, photo: [], pages: [] }; + return { + calls, + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => { + calls.nearest++; + return nearest; + }, + getPhoto: async (id) => { + calls.photo.push(id); + for (const [seq, indexes] of Object.entries(sequences)) + for (const index of indexes) + if (`${seq}-${index}` === id) return photoRecord(id, { seq, index }); + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async (seq, { page, itemsPerPage }) => { + calls.pages.push(`${seq}:${page}`); + const indexes = sequences[seq] || []; + const rows = indexes.slice( + (page - 1) * itemsPerPage, + page * itemsPerPage, + ); + return { + photos: rows.map((index) => + photoRecord(`${seq}-${index}`, { seq, index }), + ), + hasMore: page * itemsPerPage < indexes.length, + }; + }, + }; +} + +function fakeContext() { + const actions = { opened: [], nearest: [] }; + return { + actions, + context: { + services: {}, + getFilter: () => ({ pano: 'all', sinceMs: null }), + isActive: () => true, + groundCaster: null, + meshSampler: null, + getSurface: () => 'draped', + notify() {}, + actions: { + openImage: (id) => actions.opened.push(id), + openNearest: (point) => actions.nearest.push(point), + reportError() {}, + }, + }, + }; +} + +function setup(sourceOptions) { + const source = fakeSource(sourceOptions); + const loads = []; + const definition = createKartaViewProvider({ + source, + viewerOptions: { + load: async (url) => { + loads.push(url); + return { src: url }; + }, + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }, + }); + const { context, actions } = fakeContext(); + const instance = definition.create(context); + return { definition, instance, source, actions, loads }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('KartaView registers keyless, off by default, with no filters and its own wording', () => { + const { definition } = setup(); + assert.equal(definition.id, 'kartaview'); + assert.equal(definition.requiresKeyId, null); + assert.equal(definition.defaultOn, false); + assert.deepEqual(definition.filters, []); + assert.match( + definition.credit.html, + /KartaView<\/a> contributors, CC BY-SA 4\.0/, + ); + assert.equal(definition.colors.coverage, '#635bff'); + assert.deepEqual(definition.coverageLabel, { + noun: 'streets', + action: 'click a line for its nearest photo', + }); + // Its prefix fits beside the others. + validateProviders([ + { ...definition, id: 'mapillary', pickPrefix: 'mly:' }, + { ...definition, id: 'google', pickPrefix: 'gsv:' }, + definition, + ]); +}); + +test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => { + assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); + assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.equal(kartaViewZoomFor(6_001), null); + assert.equal(kartaViewZoomFor(NaN), null); +}); + +test('the nearest photo is the closest within 50 m, and opening it asks nothing more', async () => { + const { instance, source, loads } = setup({ + nearest: [ + photoRecord('far', { metres: 80 }), + photoRecord('mid', { metres: 30 }), + photoRecord('near', { metres: 12 }), + ], + }); + const id = await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }); + assert.equal(id, 'near'); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open(id); + assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache'); + assert.equal( + loads[0], + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg', + ); + const none = setup({ nearest: [photoRecord('far', { metres: 80 })] }); + assert.equal( + await none.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + null, + ); +}); + +test('a cone opens its photo; a line opens the photo nearest the click', () => { + const { instance, actions } = setup(); + assert.equal(instance.handlePick('kv:img:123'), true); + assert.deepEqual(actions.opened, ['123']); + const point = { lon: -83.0458, lat: 42.3314 }; + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }), + true, + ); + assert.deepEqual(actions.nearest, [point]); + // Over the sky the line is still ours, but there is nowhere to look. + assert.equal( + instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => null }), + true, + ); + assert.equal(actions.nearest.length, 1); + assert.equal(instance.handlePick('mly:seq:1'), false); +}); + +test('coverage stats report KartaView as configured and key-free', async () => { + const { instance } = setup(); + assert.deepEqual(await instance.status(), { configured: true }); + const stats = instance.coverageStats(); + assert.equal(stats.keyRequired, false); + assert.equal(stats.keyRejected, false); + assert.equal(stats.count, 0); +}); + +test('› walks across sequence pages; ‹ walks back; the ends stop', async () => { + const size = SEQUENCE_PAGE_SIZE; + const indexes = Array.from({ length: size + 5 }, (_, i) => i); + const { instance, source } = setup({ sequences: { s: indexes } }); + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const [, , prev, next] = host.children[0].children; + const press = async (button) => { + button.fire('click'); + for (let i = 0; i < 6; i++) await settle(); + }; + await instance.viewer.open(`s-${size - 1}`); + await press(next); + assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); + assert.deepEqual(source.calls.pages, ['s:1', 's:2']); + await press(prev); + assert.equal(poses.at(-1), `s-${size - 1}`); + await instance.viewer.open('s-0'); + await press(prev); + assert.equal(poses.at(-1), 's-0', 'nothing before the first photo'); + assert.equal(prev.disabled, true); +}); + +test('a page with missing photos is found by stepping back a page', async () => { + const size = SEQUENCE_PAGE_SIZE; + // Index `size + 100` sits on page 1 when 200 earlier photos are gone. + const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter( + (i) => i < 100 || i >= 300, + ); + const { instance, source } = setup({ sequences: { s: indexes } }); + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const target = size + 100; + await instance.viewer.open(`s-${target}`); + const [, , , next] = host.children[0].children; + next.fire('click'); + for (let i = 0; i < 8; i++) await settle(); + assert.equal(poses.at(-1), `s-${target + 1}`); + assert.deepEqual(source.calls.pages.slice(0, 2), ['s:2', 's:1']); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js new file mode 100644 index 000000000..7d68c5f62 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -0,0 +1,226 @@ +import { + KARTAVIEW_API_HOST, + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, + PHOTO_API_CONCURRENCY, + PHOTO_API_RETRIES, + PHOTO_API_RETRY_BASE_MS, + PHOTO_API_TIMEOUT_MS, + TILE_TIMEOUT_MS, +} from './policy.js'; + +/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */ +export class KartaViewSourceError extends Error { + constructor(message, { status = 0, retryAfterSec = null } = {}) { + super(message); + this.name = 'KartaViewSourceError'; + this.status = status; + this.retryAfterSec = retryAfterSec; + } +} + +const NO_ANSWER = 'KartaView did not answer — check the network and try again'; + +/** Resolves after `ms`, or rejects when `signal` aborts first. */ +function wait(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(done, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + function done() { + signal?.removeEventListener('abort', onAbort); + resolve(); + } + }); +} + +/** Seconds from a Retry-After header, or null. */ +function retryAfterSeconds(response) { + const header = response.headers?.get?.('retry-after'); + const value = header == null || header === '' ? NaN : Number(header); + return Number.isFinite(value) && value >= 0 ? value : null; +} + +/** A first-in, first-out gate letting at most `limit` tasks run at once. */ +export function createLimiter(limit) { + let running = 0; + const queue = []; + function next() { + while (running < limit && queue.length) { + const { task, resolve, reject, signal } = queue.shift(); + if (signal?.aborted) { + reject(signal.reason); + continue; + } + running++; + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => { + running--; + next(); + }); + } + } + return { + run(task, { signal } = {}) { + return new Promise((resolve, reject) => { + const item = { task, resolve, reject, signal }; + queue.push(item); + signal?.addEventListener( + 'abort', + () => { + const at = queue.indexOf(item); + if (at === -1) return; + queue.splice(at, 1); + reject(signal.reason); + }, + { once: true }, + ); + next(); + }); + }, + get pending() { + return running + queue.length; + }, + }; +} + +/** + * KartaView's public API, browser-side and keyless (it allows any origin). + * Coverage tiles are not rate-limited; photo lookups are, so they queue two + * at a time and retry a 429. + * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options] + */ +export function createKartaViewSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + apiHost = KARTAVIEW_API_HOST, + retryBaseMs = PHOTO_API_RETRY_BASE_MS, +} = {}) { + const limiter = createLimiter(PHOTO_API_CONCURRENCY); + + /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ + async function getJson(url, { signal, timeoutMs }) { + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => controller.abort(new KartaViewSourceError(NO_ANSWER)), + timeoutMs, + ); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + if (response.status === 429) + throw new KartaViewSourceError('KartaView is rate-limiting requests', { + status: 429, + retryAfterSec: retryAfterSeconds(response) ?? 2, + }); + if (!response.ok) + throw new KartaViewSourceError( + `KartaView answered HTTP ${response.status}`, + { status: response.status }, + ); + return await response.json(); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + if (error instanceof KartaViewSourceError) throw error; + throw new KartaViewSourceError(NO_ANSWER); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + /** A photo-API call through the limiter, retrying 429s with backoff. */ + async function photoApi(path, params, { signal } = {}) { + const query = params ? `?${new URLSearchParams(params)}` : ''; + const url = `${apiHost}${path}${query}`; + for (let attempt = 0; ; attempt++) { + try { + const payload = await limiter.run( + () => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }), + { signal }, + ); + const code = Number(payload?.status?.apiCode); + // 600 ok, 601 empty, 602 partial: anything else is a refusal. + if (Number.isFinite(code) && (code < 600 || code > 603)) + throw new KartaViewSourceError( + String( + payload?.status?.apiMessage || 'KartaView refused the request', + ).trim(), + { status: Number(payload?.status?.httpCode) || 0 }, + ); + return payload?.result ?? {}; + } catch (error) { + if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error; + await wait( + Math.max( + retryBaseMs * 2 ** attempt, + (error.retryAfterSec ?? 0) * 1000, + ), + signal, + ); + } + } + } + + return { + /** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */ + async getTile({ x, y, z }, { signal } = {}) { + return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, { + signal, + timeoutMs: TILE_TIMEOUT_MS, + }); + }, + + /** Photo records around a point, nearest first. */ + async nearestPhotos({ lat, lon }, { signal } = {}) { + const result = await photoApi( + '/2.0/photo/', + { + lat: lat.toFixed(7), + lng: lon.toFixed(7), + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + }, + { signal }, + ); + return Array.isArray(result.data) ? result.data : []; + }, + + /** One photo record. */ + async getPhoto(photoId, { signal } = {}) { + const result = await photoApi( + `/2.0/photo/${encodeURIComponent(photoId)}`, + null, + { signal }, + ); + if (!result.data || typeof result.data !== 'object') + throw new KartaViewSourceError('KartaView has no such photo'); + return result.data; + }, + + /** A page of a sequence's photos in capture order (1-based `page`). */ + async getSequencePhotos( + sequenceId, + { page, itemsPerPage }, + { signal } = {}, + ) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, + { page: String(page), itemsPerPage: String(itemsPerPage) }, + { signal }, + ); + return { + photos: Array.isArray(result.data) ? result.data : [], + hasMore: result.hasMoreData === true, + }; + }, + }; +} diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs new file mode 100644 index 000000000..8d8bca033 --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + KartaViewSourceError, + createKartaViewSource, + createLimiter, +} from './source.js'; +import { PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES } from './policy.js'; + +const ok = (body) => ({ + ok: true, + status: 200, + headers: new Map(), + json: async () => body, +}); +const status = (code, headers = new Map()) => ({ + ok: false, + status: code, + headers, + json: async () => ({}), +}); +const photoPayload = (data) => ({ + status: { apiCode: 600, httpCode: 200 }, + result: { data, hasMoreData: false }, +}); + +test('coverage tiles come from api.kartaview.org as JSON', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(url); + return ok({ lines: [] }); + }, + }); + assert.deepEqual(await source.getTile({ x: 4412, y: 6061, z: 14 }), { + lines: [], + }); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/4412/6061/14.json', + ]); +}); + +test('photo lookups run at most two at a time', async () => { + let running = 0; + let peak = 0; + const source = createKartaViewSource({ + fetchImpl: async () => { + running++; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + return ok(photoPayload({ id: '1' })); + }, + }); + await Promise.all( + Array.from({ length: 7 }, (_, i) => source.getPhoto(String(i))), + ); + assert.equal(peak, PHOTO_API_CONCURRENCY); +}); + +test('a 429 is retried after a wait, then answered', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return calls < 3 + ? status(429, new Map([['retry-after', '0']])) + : ok(photoPayload([{ id: '9' }])); + }, + }); + const records = await source.nearestPhotos({ lat: 42.3, lon: -83.04 }); + assert.deepEqual(records, [{ id: '9' }]); + assert.equal(calls, 3); +}); + +test('rate-limiting that outlasts the retries is reported with a wait', async () => { + let calls = 0; + const source = createKartaViewSource({ + retryBaseMs: 1, + fetchImpl: async () => { + calls++; + return status(429, new Map([['retry-after', '0.001']])); + }, + }); + await assert.rejects(source.getPhoto('1'), (error) => { + assert.ok(error instanceof KartaViewSourceError); + assert.equal(error.status, 429); + assert.ok(error.retryAfterSec > 0); + return true; + }); + assert.equal(calls, PHOTO_API_RETRIES + 1); +}); + +test('an API refusal inside a 200 is an error with KartaView’s message; 601 is just empty', async () => { + const refusing = createKartaViewSource({ + fetchImpl: async () => + ok({ + status: { apiCode: 612, apiMessage: ' out of range ', httpCode: 400 }, + result: {}, + }), + }); + await assert.rejects(refusing.getPhoto('1'), /out of range/); + const empty = createKartaViewSource({ + fetchImpl: async () => + ok({ status: { apiCode: 601 }, result: { data: [] } }), + }); + assert.deepEqual(await empty.nearestPhotos({ lat: 0, lon: 0 }), []); + const page = await empty.getSequencePhotos('2008', { + page: 2, + itemsPerPage: 1000, + }); + assert.deepEqual(page, { photos: [], hasMore: false }); +}); + +test('HTTP errors, network failures and non-JSON become readable errors', async () => { + const http = createKartaViewSource({ fetchImpl: async () => status(500) }); + await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/); + const offline = createKartaViewSource({ + fetchImpl: async () => { + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects(offline.getPhoto('1'), /did not answer/); + const garbled = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + }), + }); + await assert.rejects( + garbled.getTile({ x: 0, y: 0, z: 14 }), + /did not answer/, + ); +}); + +test('an abort stops a queued lookup without fetching, and wins over a retry wait', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const source = createKartaViewSource({ + retryBaseMs: 10_000, + fetchImpl: async (url) => { + calls++; + if (url.includes('/slow')) await gate; + return status(429); + }, + }); + // Two slow lookups fill the queue; the third waits for a slot. + const first = new AbortController(); + const slow = [ + source.getPhoto('slow', { signal: first.signal }), + source.getPhoto('slow', { signal: first.signal }), + ]; + const controller = new AbortController(); + const queued = source.getPhoto('queued', { signal: controller.signal }); + controller.abort(new Error('overtaken')); + await assert.rejects(queued, /overtaken/); + assert.equal(calls, 2, 'the queued lookup never fetched'); + release(); + // Each slow one is now waiting 10 s to retry; nothing else is asked. + const retrying = new AbortController(); + const waiting = source.getPhoto('slow', { signal: retrying.signal }); + await new Promise((resolve) => setTimeout(resolve, 20)); + retrying.abort(new Error('closed')); + await assert.rejects(waiting, /closed/); + // The two first lookups are still waiting out their backoff: drop them. + first.abort(new Error('done')); + for (const promise of slow) await assert.rejects(promise, /done/); +}); + +test('the limiter runs tasks in order and keeps going after a failure', async () => { + const limiter = createLimiter(1); + const order = []; + const failing = limiter.run(async () => { + order.push('a'); + throw new Error('a failed'); + }); + const second = limiter.run(async () => { + order.push('b'); + return 'b'; + }); + await assert.rejects(failing, /a failed/); + assert.equal(await second, 'b'); + assert.deepEqual(order, ['a', 'b']); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(limiter.pending, 0); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js new file mode 100644 index 000000000..27c5bee6b --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -0,0 +1,422 @@ +import { + createPanoramaRenderer, + PANO_FOV_DEFAULT_DEG, + wrapDegrees, +} from './panorama.js'; +import { + FLAT_FULL_SIZE, + IMAGE_TIMEOUT_MS, + KARTAVIEW_PROVIDER_ID, + PANO_FULL_SIZE, + PANO_PREVIEW_SIZE, + kartaViewPhotoUrl, + resizedImageUrl, +} from './policy.js'; + +const LOAD_FAILED = 'The KartaView photo could not be loaded'; + +/** Load an image (CORS-clean, for WebGL) within the timeout. */ +export function loadImage( + url, + { crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, +) { + return new Promise((resolve, reject) => { + if (!url) return reject(new Error(LOAD_FAILED)); + const image = new Image(); + if (crossOrigin) image.crossOrigin = 'anonymous'; + image.decoding = 'async'; + const timer = setTimeout(() => { + image.src = ''; + reject(new Error(LOAD_FAILED)); + }, timeoutMs); + image.onload = () => { + clearTimeout(timer); + resolve(image); + }; + image.onerror = () => { + clearTimeout(timer); + reject(new Error(LOAD_FAILED)); + }; + image.src = url; + }); +} + +/** + * Viewer adapter for KartaView photos: a flat photo in an , a 360° + * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step + * along the sequence (also ← →). Poses follow the view direction. + * + * @param {object} options + * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). + * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. + * @param {(imageId: string) => void} [options.onStepped] A photo opened from ‹ ›, after its pose. + * @param {object} [options.render] Render governor. + * @param {(url: string, options?: object) => Promise} [options.load] + * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] + * @returns {import('../../registry.js').ViewerAdapter} + */ +export function createKartaViewViewer({ + getPhoto, + step, + onStepped = () => {}, + render, + load = loadImage, + createRenderer = createPanoramaRenderer, +}) { + const listeners = new Set(); + let root = null; + let picture = null; + let canvas = null; + let prevButton = null; + let nextButton = null; + let renderer = null; + /** WebGL failed once: 360° photos show their front view instead. */ + let rendererFailed = false; + let photo = null; + /** What shows: 'flat', 'pano' or null. */ + let showing = null; + let openSeq = 0; + let renderMode = 'letterbox'; + let drag = null; + let poseFrame = 0; + let removeListeners = []; + + function requestRender() { + render?.governorRequestRender?.('kartaview-viewer'); + } + + function emit() { + if (!photo || !showing) return; + const view = showing === 'pano' ? renderer.getView() : null; + const heading = photo.heading; + const pose = { + providerId: KARTAVIEW_PROVIDER_ID, + imageId: photo.id, + position: { lon: photo.lon, lat: photo.lat }, + bearing: + heading === null + ? null + : view + ? wrapDegrees(heading + view.yaw) + : heading, + tilt: view ? view.pitch : 0, + altitude: null, + isPano: showing === 'pano', + capturedAt: photo.capturedAt, + capturedAtPrecision: 'day', + // KartaView's only place name is the sequence's, where the drive began. + title: null, + creator: null, + sequenceId: photo.sequenceId, + externalUrl: kartaViewPhotoUrl(photo), + }; + for (const listener of [...listeners]) { + try { + listener(pose); + } catch { + /* listener errors are the core's to log */ + } + } + requestRender(); + } + + /** Re-emit on the next frame: a drag moves the view many times a frame. */ + function emitSoon() { + if (poseFrame) return; + poseFrame = requestAnimationFrame(() => { + poseFrame = 0; + emit(); + }); + } + + function syncButtons() { + const enabled = Boolean(photo?.sequenceId) && showing !== null; + if (prevButton) prevButton.disabled = !enabled; + if (nextButton) nextButton.disabled = !enabled; + } + + function show(kind) { + showing = kind; + if (picture) picture.hidden = kind !== 'flat'; + if (canvas) canvas.hidden = kind !== 'pano'; + if (root) root.dataset.kind = kind || ''; + syncButtons(); + } + + function applyRenderMode() { + if (picture) + picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain'; + } + + function ensureRenderer() { + if (renderer || rendererFailed || !canvas) return renderer; + try { + renderer = createRenderer(canvas); + } catch { + rendererFailed = true; + renderer = null; + } + return renderer; + } + + /** Step along the sequence from the open photo. */ + async function stepBy(delta) { + const from = photo; + if (!from?.sequenceId || !showing) return; + const seq = openSeq; + let nextId = null; + try { + nextId = await step(from, delta); + } catch { + nextId = null; + } + if (seq !== openSeq || photo !== from) return; + if (!nextId) { + const button = delta < 0 ? prevButton : nextButton; + if (button) button.disabled = true; + return; + } + try { + await adapter.open(nextId, { keepView: true }); + } catch { + return; + } + if (photo?.id === nextId) onStepped(nextId); + } + + function onPointerDown(event) { + if (showing !== 'pano' || event.button !== 0) return; + drag = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + view: renderer.getView(), + }; + canvas.setPointerCapture?.(event.pointerId); + canvas.classList.add('sl-kv-dragging'); + } + + function onPointerMove(event) { + if (!drag || event.pointerId !== drag.id) return; + const { fov } = drag.view; + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + const vertical = fov; + const horizontal = fov * (width / height); + renderer.setView({ + yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal, + pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical, + }); + emitSoon(); + } + + function onPointerUp(event) { + if (!drag || event.pointerId !== drag.id) return; + drag = null; + canvas.classList.remove('sl-kv-dragging'); + } + + function onWheel(event) { + if (showing !== 'pano') return; + event.preventDefault(); + const { fov } = renderer.getView(); + renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) }); + } + + function onKeyDown(event) { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + stepBy(event.key === 'ArrowLeft' ? -1 : 1); + } + } + + function listen(target, type, handler, options) { + target.addEventListener(type, handler, options); + removeListeners.push(() => + target.removeEventListener(type, handler, options), + ); + } + + function build(host) { + const doc = host.ownerDocument; + root = doc.createElement('div'); + root.className = 'sl-kv-viewer'; + root.tabIndex = 0; + root.setAttribute('role', 'group'); + root.setAttribute( + 'aria-label', + 'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence', + ); + picture = doc.createElement('img'); + picture.className = 'sl-kv-photo'; + picture.alt = 'KartaView street-level photo'; + picture.draggable = false; + picture.hidden = true; + canvas = doc.createElement('canvas'); + canvas.className = 'sl-kv-pano'; + canvas.hidden = true; + const nav = (label, glyph, delta) => { + const button = doc.createElement('button'); + button.type = 'button'; + button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`; + button.textContent = glyph; + button.title = label; + button.setAttribute('aria-label', label); + button.disabled = true; + listen(button, 'click', (event) => { + event.stopPropagation(); + stepBy(delta); + }); + return button; + }; + prevButton = nav('Previous photo in this sequence (←)', '‹', -1); + nextButton = nav('Next photo in this sequence (→)', '›', 1); + root.append(picture, canvas, prevButton, nextButton); + host.append(root); + listen(canvas, 'pointerdown', onPointerDown); + listen(canvas, 'pointermove', onPointerMove); + listen(canvas, 'pointerup', onPointerUp); + listen(canvas, 'pointercancel', onPointerUp); + listen(canvas, 'wheel', onWheel, { passive: false }); + listen(root, 'keydown', onKeyDown); + applyRenderMode(); + } + + function teardown() { + for (const remove of removeListeners) remove(); + removeListeners = []; + if (poseFrame) cancelAnimationFrame(poseFrame); + poseFrame = 0; + drag = null; + renderer?.destroy(); + renderer = null; + root?.remove(); + root = picture = canvas = prevButton = nextButton = null; + showing = null; + photo = null; + } + + /** A sharper version once the first is on screen, if nothing replaced it. */ + async function upgrade(target, seq, kind) { + // A 360° photo shown flat (no WebGL) keeps its front view. + if (kind === 'flat' && target.isPano) return; + const url = + kind === 'pano' + ? resizedImageUrl(target.urls.pano, PANO_FULL_SIZE) + : resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); + if (!url) return; + try { + const image = await load(url, { crossOrigin: kind === 'pano' }); + if (seq !== openSeq || photo !== target || showing !== kind) return; + if (kind === 'pano') renderer.setImage(image); + else picture.src = image.src; + requestRender(); + } catch { + /* the first version stays */ + } + } + + async function showPano(target, seq, keepView) { + const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE); + let image; + try { + image = await load(preview, { crossOrigin: true }); + } catch { + image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), { + crossOrigin: true, + }); + } + if (seq !== openSeq) return false; + if (!keepView || showing !== 'pano') + renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); + renderer.setImage(image); + photo = target; + show('pano'); + return true; + } + + async function showFlat(target, seq) { + let image; + try { + image = await load(target.urls.view, { crossOrigin: false }); + } catch (error) { + // No 1280 px rendition: the proxy can shrink the full-size photo. + const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); + if (target.isPano || !fallback || seq !== openSeq) throw error; + image = await load(fallback, { crossOrigin: false }); + } + if (seq !== openSeq) return false; + picture.src = image.src; + photo = target; + show('flat'); + return true; + } + + const adapter = { + async mount(host) { + if (!host) throw new Error('Street Level viewer has no host element'); + if (root && root.parentNode === host) return; + teardown(); + build(host); + }, + + /** Resolves once the photo is on screen and its pose was emitted. */ + async open(imageId, { keepView = false } = {}) { + if (!root) throw new Error('KartaView viewer is not mounted'); + const seq = ++openSeq; + const target = await getPhoto(String(imageId)); + if (seq !== openSeq) return; + let shown = false; + if (target.isPano && target.urls.pano && ensureRenderer()) { + try { + shown = await showPano(target, seq, keepView); + } catch { + // The panorama failed: its front view is better than nothing. + if (seq !== openSeq) return; + shown = await showFlat(target, seq); + } + } else shown = await showFlat(target, seq); + if (!shown) return; + emit(); + upgrade(target, seq, showing); + }, + + close() { + openSeq++; + photo = null; + show(null); + if (picture) picture.removeAttribute('src'); + renderer?.clear?.(); + }, + + unmount() { + openSeq++; + teardown(); + }, + + resize() { + renderer?.resize(); + }, + + /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ + supportsRenderModes: () => showing !== 'pano', + + setRenderMode(mode) { + renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + applyRenderMode(); + }, + + onPose(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return adapter; +} diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs new file mode 100644 index 000000000..f15c6f3fc --- /dev/null +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createKartaViewViewer } from './viewer.js'; +import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; + +const PHOTOS = { + flat1: { + id: 'flat1', + sequenceId: 's1', + sequenceIndex: 1, + lon: -83.04, + lat: 42.33, + heading: 90, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/a.jpg', + full: 'https://storage2.openstreetcam.org/proc/a.jpg', + pano: null, + }, + }, + flat2: { + id: 'flat2', + sequenceId: 's1', + sequenceIndex: 2, + lon: -83.0401, + lat: 42.33, + heading: 92, + isPano: false, + capturedAt: Date.UTC(2016, 7, 23), + urls: { + view: 'https://storage2.openstreetcam.org/lth/b.jpg', + full: 'https://storage2.openstreetcam.org/proc/b.jpg', + pano: null, + }, + }, + pano1: { + id: 'pano1', + sequenceId: 's2', + sequenceIndex: 7, + lon: 106.84, + lat: -6.19, + heading: 150, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/p.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', + }, + }, +}; + +function fakeRenderer() { + const view = { yaw: 0, pitch: 0, fov: 80 }; + return { + images: [], + setImage(image) { + this.images.push(image.src); + }, + setView(next) { + Object.assign(view, next); + }, + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() { + this.destroyed = true; + }, + }; +} + +function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { + const loads = []; + const renderers = []; + const stepped = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!PHOTOS[id]) throw new Error('no such photo'); + return PHOTOS[id]; + }, + step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null, + onStepped: (id) => stepped.push(id), + load: async (url, options) => { + loads.push({ url, crossOrigin: options?.crossOrigin }); + if (failLoads.some((part) => url.includes(part))) + throw new Error('The KartaView photo could not be loaded'); + return { src: url }; + }, + createRenderer: () => { + if (rendererThrows) throw new Error('WebGL is unavailable'); + const renderer = fakeRenderer(); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + const host = fakeHost(); + return { viewer, poses, host, loads, renderers, stepped }; +} + +const parts = (host) => { + const root = host.children[0]; + const [picture, canvas, prev, next] = root.children; + return { root, picture, canvas, prev, next }; +}; +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => { + const { viewer, poses, host, loads } = setup(); + await viewer.mount(host); + const { picture, canvas, prev, next } = parts(host); + assert.equal(prev.disabled, true, 'nothing to step through yet'); + await viewer.open('flat1'); + assert.equal(picture.hidden, false); + assert.equal(canvas.hidden, true); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false); + const pose = poses.at(-1); + assert.deepEqual( + { + providerId: pose.providerId, + imageId: pose.imageId, + position: pose.position, + bearing: pose.bearing, + tilt: pose.tilt, + isPano: pose.isPano, + sequenceId: pose.sequenceId, + capturedAt: pose.capturedAt, + title: pose.title, + }, + { + providerId: 'kartaview', + imageId: 'flat1', + position: { lon: -83.04, lat: 42.33 }, + bearing: 90, + tilt: 0, + isPano: false, + sequenceId: 's1', + capturedAt: Date.UTC(2016, 7, 23), + title: null, + }, + ); + assert.equal(pose.externalUrl, PHOTOS.flat1.urls.full); + assert.equal(loads[0].url, PHOTOS.flat1.urls.view); + assert.equal(loads[0].crossOrigin, false, 'an needs no CORS'); + await settle(); + assert.match( + loads[1].url, + /^https:\/\/cdn\.kartaview\.org\/.*rs:fit:2560:2560/, + ); + assert.equal(picture.src, loads[1].url, 'upgraded to the sharp version'); + assert.equal(viewer.supportsRenderModes(), true); + viewer.setRenderMode('fill'); + assert.equal(picture.style.objectFit, 'cover'); +}); + +test('a missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => { + const { viewer, host, loads } = setup({ failLoads: ['/lth/'] }); + await viewer.mount(host); + await viewer.open('flat1'); + assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(parts(host).picture.hidden, false); +}); + +test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => { + const { viewer, poses, host, loads, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const { picture, canvas } = parts(host); + assert.equal(canvas.hidden, false); + assert.equal(picture.hidden, true); + assert.match(loads[0].url, /rs:fit:1024:512/); + assert.equal(loads[0].crossOrigin, true, 'WebGL textures need CORS'); + assert.equal(poses.at(-1).isPano, true); + assert.equal(poses.at(-1).bearing, 150); + assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); + await settle(); + assert.match(renderers[0].images.at(-1), /rs:fit:4096:2048/); + // Drag left by a quarter of the width: the view turns right. + const frames = []; + globalThis.requestAnimationFrame = (callback) => { + frames.push(callback); + return frames.length; + }; + try { + canvas.fire('pointerdown', { + button: 0, + pointerId: 1, + clientX: 200, + clientY: 100, + }); + canvas.fire('pointermove', { pointerId: 1, clientX: 100, clientY: 100 }); + canvas.fire('pointerup', { pointerId: 1 }); + frames.forEach((callback) => callback()); + } finally { + delete globalThis.requestAnimationFrame; + } + const turned = poses.at(-1).bearing; + assert.ok(turned > 150 && turned < 360, `bearing ${turned} turned right`); +}); + +test('without WebGL, or when the panorama fails, a 360° photo shows its front view', async () => { + const noGl = setup({ rendererThrows: true }); + await noGl.viewer.mount(noGl.host); + await noGl.viewer.open('pano1'); + assert.equal(parts(noGl.host).picture.hidden, false); + assert.equal(noGl.loads[0].url, PHOTOS.pano1.urls.view); + assert.equal(noGl.poses.at(-1).isPano, false); + await settle(); + assert.equal( + noGl.loads.length, + 1, + 'the equirectangular image never replaces it', + ); + + const broken = setup({ failLoads: ['cdn.kartaview.org'] }); + await broken.viewer.mount(broken.host); + await broken.viewer.open('pano1'); + assert.equal(parts(broken.host).picture.hidden, false); + assert.equal(broken.poses.at(-1).imageId, 'pano1'); +}); + +test('‹ › and the arrow keys step along the sequence; the end disables the button', async () => { + const { viewer, poses, host, stepped } = setup({ + steps: { 'flat1:1': 'flat2' }, + }); + await viewer.mount(host); + await viewer.open('flat1'); + const { root, prev, next } = parts(host); + next.fire('click'); + await settle(); + await settle(); + assert.equal(poses.at(-1).imageId, 'flat2'); + assert.deepEqual(stepped, ['flat2']); + root.fire('keydown', { key: 'ArrowRight' }); + await settle(); + await settle(); + assert.equal(next.disabled, true, 'flat2 is the last photo'); + root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); + await settle(); + assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours'); + assert.equal(prev.disabled, false); +}); + +test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => { + const { viewer, poses, host, renderers } = setup(); + await viewer.mount(host); + const first = viewer.open('pano1'); + const second = viewer.open('flat1'); + await Promise.all([first, second]); + assert.deepEqual( + poses.map((pose) => pose.imageId), + ['flat1'], + 'the overtaken panorama never posed', + ); + assert.equal(renderers.length, 0, 'nor built a WebGL context'); + await viewer.open('pano1'); + viewer.close(); + const { picture, canvas, root } = parts(host); + assert.equal(canvas.hidden, true); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + await assert.rejects(viewer.open('nope'), /no such photo/); + viewer.unmount(); + assert.equal(host.children.length, 0); + assert.equal(renderers[0].destroyed, true); + await assert.rejects(viewer.open('flat1'), /not mounted/); +}); diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 5b9b5246c..e08a578e4 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,21 +1,11 @@ -import * as Cesium from 'cesium'; -import { decodeCoverageTile } from './decode.js'; -import { passesImageryFilter } from '../../filter.js'; import { - createHorizonCull, - groundUnderCamera, - metresBetween, - viewFocus, - visibleBbox, - whenIdle, -} from '../../view.js'; -import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; -import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js'; + createLineCoverage, + sequenceIdFromPick as lineSequenceIdFromPick, +} from '../../lineCoverage.js'; +import { decodeCoverageTile } from './decode.js'; import { coverageZoomForHeight, overviewZoomForHeight, - tileBounds, - tilesForBbox, } from '../../tileMath.js'; import { COLORS, @@ -33,885 +23,48 @@ import { SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; -/** Draped lines stay at most this long while a tile's cast lines build. */ -export const SWAP_MAX_WAIT_MS = 4000; -/** Tiles touched by new mesh samples are redrawn at most this often. */ -const REMESH_INTERVAL_MS = 1500; -/** Gap between redrawing one dirty tile and the next. */ -const REMESH_STAGGER_MS = 120; -/** Old-zoom tiles are kept at most this long after a zoom change. */ -const STALE_TILE_MAX_MS = 6000; -/** Sequences per primitive: smaller batches build, and show, sooner. */ -const SEQUENCE_PRIMITIVE_BATCH = 120; +export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js'; -const PER_TILE_SEQUENCE_CAP = Math.floor( - COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES, -); - -/** Colour for a sequence: Mapillary green, GEV cyan while selected. */ -function sequenceColor({ selected = false } = {}) { - return selected - ? Cesium.Color.fromCssColorString(COLORS.selected) - : Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92); -} - -/** Separates a multi-part sequence's part index from its id in pick ids. */ -const PART_SEPARATOR = '~'; - -/** Pick ids for every part of a sequence; the first part is unsuffixed. */ -function partIds(sequence) { - return sequence.parts.map((_, index) => - index - ? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}` - : `${PICK_PREFIX.sequence}${sequence.id}`, - ); +/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */ +export function mapillaryZoomFor(heightM) { + const sequence = coverageZoomForHeight(heightM); + if (sequence != null) return { zoom: sequence, kind: 'sequence' }; + const overview = overviewZoomForHeight(heightM); + return overview != null ? { zoom: overview, kind: 'overview' } : null; } /** Sequence id for any part's pick id (`mly:seq:[~]`), else null. */ export function sequenceIdFromPick(pickId) { - if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence)) - return null; - const rest = pickId.slice(PICK_PREFIX.sequence.length); - const cut = rest.indexOf(PART_SEPARATOR); - return cut === -1 ? rest : rest.slice(0, cut); + return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence); } -/** - * Whether any point of `box` ({west, south, east, north}) is within the mesh - * sampler's range of `centre`, the ground point under the camera. - */ -export function meshBoxInRange(box, centre) { - // Move the camera's longitude to the box's side of the date line first: - // clamping 179.999 into a box at -179.99 would pick its far edge. - const lon = - centre.lon + - 360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360); - const nearest = { - lon: Math.min(box.east, Math.max(box.west, lon)), - lat: Math.min(box.north, Math.max(box.south, centre.lat)), - }; - return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; -} - -/** - * Camera-driven coverage: overview points (z0–5) from orbit, sequence lines - * (z11–14) near the ground. In terrain surface mode a tile is drawn draped - * first, then swapped for lines cast to the bare earth (draped lines would - * land on roofs), so coverage never waits on terrain heights. - */ +/** Mapillary's vector-tile coverage through the server proxy. */ export function createCoverage({ state, source }) { - const { render } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-coverage'); - } - - function ensureTerrainReady() { - return (state.coverage.terrainReady ||= - Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); - } - - function tileKey({ x, y, z }) { - return `${z}/${x}/${y}`; - } - - /** The imagery filter, resolved now: "since N days" moves with the clock. */ - function filter() { - return state.context.getFilter(); - } - - function terrainMode() { - return ( - state.context.getSurface?.() === 'terrain' && - Boolean(state.context.groundCaster) - ); - } - - /** - * A tile's sequences, filtered then capped: capping first could leave a - * dense tile of newer flat captures with no 360° lines at all. - */ - function drawnSequences(entry) { - const drawn = []; - const current = filter(); - for (const sequence of entry.sequenceList) { - if (drawn.length >= PER_TILE_SEQUENCE_CAP) break; - if (passesImageryFilter(sequence, current)) drawn.push(sequence); - } - return drawn; - } - - /** - * Batched primitives for a tile's sequence parts: cast polylines where - * terrain heights are cached, draped ground lines otherwise. Each batch - * records the selection its colours were built with. - */ - function buildSequencePrimitives(sequences) { - const ground = terrainMode() ? state.context.groundCaster : null; - const meshAt = ground ? state.context.meshSampler?.meshAt : undefined; - const selectedId = state.sequence.selectedId ?? null; - const draped = []; - const cast = []; - for (const sequence of sequences) { - const color = Cesium.ColorGeometryInstanceAttribute.fromColor( - sequenceColor({ selected: sequence.id === selectedId }), - ); - const ids = partIds(sequence); - sequence.parts.forEach((coordinates, index) => { - const flat = ground?.castLine(coordinates, { meshAt }); - let positions; - try { - positions = flat - ? Cesium.Cartesian3.fromDegreesArrayHeights(flat) - : Cesium.Cartesian3.fromDegreesArray(coordinates.flat()); - } catch { - return; - } - if (positions.length < 2) return; - const geometry = flat - ? new Cesium.PolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - arcType: Cesium.ArcType.NONE, - }) - : new Cesium.GroundPolylineGeometry({ - positions, - width: COVERAGE_LINE_WIDTH_PX, - }); - (flat ? cast : draped).push( - new Cesium.GeometryInstance({ - geometry, - id: ids[index], - attributes: { color }, - }), - ); - }); - } - const primitives = []; - for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: true, - selectedId, - primitive: new Cesium.GroundPolylinePrimitive({ - geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance(), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - allowPicking: true, - }), - }); - for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH) - primitives.push({ - onGround: false, - selectedId, - primitive: new Cesium.Primitive({ - geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - asynchronous: true, - allowPicking: true, - }), - }); - return { primitives, draped, cast: cast.length }; - } - - /** - * Fetch the terrain heights a tile's lines need, then redraw it cast. - * `castRequested` stops the redraw from casting again at once; a refresh - * `retry`s a tile whose lines are still draped (a failed or partial cast), - * and it is redrawn only when more of its lines can be cast. - */ - function castTile(entry, { retry = false } = {}) { - if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode()) - return; - entry.castRequested = true; - entry.castAbort = new AbortController(); - const { signal } = entry.castAbort; - const lines = [...entry.sequences.values()].flatMap( - (sequence) => sequence.parts, - ); - const caster = state.context.groundCaster; - caster.prepareLines(lines, { signal }).then(() => { - if (entry.castAbort?.signal === signal) entry.castAbort = null; - const attached = [...state.coverage.tiles.values()].includes(entry); - if (signal.aborted || !attached || !terrainMode()) return; - // Nothing new to cast: keep what is drawn until the next refresh. - // Counted like buildSequencePrimitives: a cast line needs two points. - const castable = lines.filter( - (coords) => caster.castLine(coords)?.length >= 6, - ).length; - if (castable <= (entry.castLines || 0)) { - entry.castRequested = (entry.castLines || 0) > 0; - return; - } - // A remesh may have cleared the flag while this was in flight. - entry.castRequested = true; - redrawTile(entry); - requestMesh(entry); - }); - } - - /** Rebuild a tile's lines; the old ones stay up until the new are ready. */ - function redrawTile(entry) { - const previous = entry.primitives; - entry.primitives = []; - attachPrimitive(entry); - removeWhenReady(entry, previous); - } - - /** Lon/lat under the camera, where mesh range is measured from. */ - function meshCentre() { - const carto = state.viewer?.camera?.positionCartographic; - if (!carto) return null; - return { - lon: Cesium.Math.toDegrees(carto.longitude), - lat: Cesium.Math.toDegrees(carto.latitude), - }; - } - - /** - * A tile's drawn lines with bounding boxes, built once per filter; mesh - * points are densified the first time a line is in range. - */ - function meshParts(entry) { - if (entry.meshParts) return entry.meshParts; - entry.meshParts = []; - for (const sequence of entry.sequences.values()) - for (const part of sequence.parts) { - const box = { - west: Infinity, - south: Infinity, - east: -Infinity, - north: -Infinity, - }; - for (const [lon, lat] of part) { - if (lon < box.west) box.west = lon; - if (lon > box.east) box.east = lon; - if (lat < box.south) box.south = lat; - if (lat > box.north) box.north = lat; - } - entry.meshParts.push({ part, box, points: null }); - } - return entry.meshParts; - } - - /** - * Ask for mesh samples under a cast tile's lines within the sampler's range - * of the camera; the rest are asked for when a camera move brings them in. - */ - function requestMesh(entry) { - const sampler = state.context.meshSampler; - if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return; - const centre = meshCentre(); - if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre)) - return; - const points = []; - for (const line of meshParts(entry)) { - if (!meshBoxInRange(line.box, centre)) continue; - line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG); - for (const point of line.points) points.push(point); - } - if (points.length) sampler.request(points); - } - - /** Redraw the tiles new mesh samples fall in, throttled. */ - function onMeshSampled(batch) { - if (!terrainMode() || !state.context.isActive()) return; - for (const entry of state.coverage.tiles.values()) { - if (entry.kind !== 'sequence' || !entry.bounds) continue; - const { west, south, east, north } = entry.bounds; - if ( - batch.some( - ([lon, lat]) => - lon >= west && lon <= east && lat >= south && lat <= north, - ) - ) - state.coverage.remeshDirty.add(entry); - } - if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return; - const wait = Math.max( - 0, - REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)), - ); - state.coverage.remeshTimer = setTimeout(remesh, wait); - } - - /** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */ - function remesh() { - state.coverage.remeshTimer = null; - state.coverage.remeshAt = Date.now(); - if (!terrainMode() || !state.context.isActive()) { - state.coverage.remeshDirty.clear(); - return; - } - const attached = new Set(state.coverage.tiles.values()); - const [entry] = state.coverage.remeshDirty; - if (!entry) return; - state.coverage.remeshDirty.delete(entry); - if (attached.has(entry)) { - redrawTile(entry); - requestRender(); - } - if (state.coverage.remeshDirty.size) - state.coverage.remeshTimer = setTimeout( - () => whenIdle(remesh, 500), - REMESH_STAGGER_MS, - ); - } - - state.coverage.remeshDirty = new Set(); - state.context.meshSampler?.onSampled(onMeshSampled); - - /** - * Remove a tile's previous primitives once all its current ones are ready - * (or after a few seconds); detaching the tile removes them at once. - */ - function removeWhenReady(entry, old) { - const scene = state.viewer?.scene; - finishSwap(entry); - if (!scene?.postRender) { - removePrimitives(old); - return; - } - const fresh = entry.primitives; - const started = Date.now(); - const stop = scene.postRender.addEventListener(() => { - const ready = fresh.every( - ({ primitive }) => primitive.ready || primitive.isDestroyed?.(), - ); - if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) { - requestRender(); - return; - } - finishSwap(entry); - requestRender(); - }); - entry.swap = { old, stop }; - requestRender(); - } - - function finishSwap(entry) { - if (!entry.swap) return; - entry.swap.stop(); - removePrimitives(entry.swap.old); - entry.swap = null; - } - - function buildOverviewCollection(points) { - const collection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( - 0.85, - ); - const drawn = []; - const current = filter(); - for (const point of points) { - if (!passesImageryFilter(point, current)) continue; - drawn.push( - collection.add({ - position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), - color: green, - pixelSize: COVERAGE_OVERVIEW_POINT_PX, - // Google 3D terrain and clouds must not hide the near side's dots; - // the horizon cull hides the far side's, which this lets through. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }), - ); - } - return { collection, points: drawn }; - } - - /** Every overview point on the globe, current zoom and old alike. */ - function* overviewPoints() { - for (const entry of drawnEntries()) - if (entry.overviewPoints) yield* entry.overviewPoints; - } - - // Overview points skip the depth test: hide the far hemisphere's. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: overviewPoints, - onChange: requestRender, + return createLineCoverage({ + state, + fetchTile: async (tile, { signal }) => + decodeCoverageTile( + await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }), + tile, + ), + zoomFor: mapillaryZoomFor, + colors: COLORS, + pickPrefix: PICK_PREFIX.sequence, + renderKey: 'mapillary-coverage', + tuning: { + maxTiles: COVERAGE_MAX_TILES, + overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES, + maxSequences: COVERAGE_MAX_SEQUENCES, + lineWidthPx: COVERAGE_LINE_WIDTH_PX, + overviewPointPx: COVERAGE_OVERVIEW_POINT_PX, + moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS, + viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M, + viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT, + viewNearM: SEQUENCE_VIEW_NEAR_M, + }, + messages: { + keyRejected: KEY_REJECTED_MESSAGE, + rateLimited: RATE_LIMITED_MESSAGE, + }, }); - - function attachPrimitive(entry) { - const scene = state.viewer?.scene; - if (!scene) return; - if (entry.kind === 'sequence') { - // Lookup, picking and counts follow what is drawn, not the whole tile. - const sequences = drawnSequences(entry); - entry.sequences = new Map(sequences.map((s) => [s.id, s])); - const { primitives, draped, cast } = buildSequencePrimitives(sequences); - entry.primitives = primitives; - entry.count = sequences.length; - for (const { primitive, onGround } of primitives) - (onGround ? scene.groundPrimitives : scene.primitives).add(primitive); - // Fewer lines cast than last time: the caster has dropped heights this - // tile used (a full cache), so it must be cast again. - if (cast < (entry.castLines || 0)) entry.castRequested = false; - entry.castLines = cast; - entry.drapedLines = draped.length; - if (draped.length) castTile(entry); - watchSelection(); - } else { - const { collection, points } = buildOverviewCollection(entry.points); - entry.primitive = collection; - entry.overviewPoints = points; - entry.count = points.length; - scene.primitives.add(collection); - // Cull the new points now, then all of them whenever the camera moves. - horizon.update(points); - } - } - - function removePrimitives(list) { - const scene = state.viewer?.scene; - for (const { primitive, onGround } of list || []) { - try { - (onGround ? scene?.groundPrimitives : scene?.primitives)?.remove( - primitive, - ); - } catch { - /* already gone */ - } - } - } - - function detachPrimitive(entry) { - const scene = state.viewer?.scene; - finishSwap(entry); - removePrimitives(entry.primitives); - entry.primitives = []; - if (entry.primitive) { - try { - scene?.primitives?.remove(entry.primitive); - } catch { - /* already gone */ - } - entry.primitive = null; - entry.overviewPoints = null; - } - } - - /** Stop a tile's pending terrain lookup (tile dropped or retired). */ - function cancelCast(entry) { - entry.castAbort?.abort(); - entry.castAbort = null; - } - - function removeTile(key) { - const entry = state.coverage.tiles.get(key); - if (!entry) return; - cancelCast(entry); - detachPrimitive(entry); - state.coverage.tiles.delete(key); - } - - async function loadTile(tile, kind) { - const key = tileKey(tile); - if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) - return; - const controller = new AbortController(); - state.coverage.pending.set(key, controller); - notify(); - try { - // Fetch and the one-time terrain-height table load run side by side, - // both handled from the start so an early tile failure is not unhandled. - const [bytes] = await Promise.all([ - source.getTile('coverage', tile.z, tile.x, tile.y, { - signal: controller.signal, - }), - kind === 'sequence' ? ensureTerrainReady() : null, - ]); - // Only an abort, a retire, a clear or a newer request for this tile - // discards the bytes; a refresh that still wants the tile keeps them. - const current = () => - state.coverage.pending.get(key) === controller && - !state.coverage.tiles.has(key); - if ( - controller.signal.aborted || - !current() || - kind !== state.coverage.kind || - tile.z !== state.coverage.zoom - ) - return; - const decoded = decodeCoverageTile(bytes, tile); - const entry = { kind, primitive: null, primitives: [], count: 0 }; - // Padded: vector tiles carry a small buffer past their edge. - const bounds = tileBounds(tile.x, tile.y, tile.z); - const pad = (bounds.east - bounds.west) * 0.05; - entry.bounds = { - west: bounds.west - pad, - east: bounds.east + pad, - south: bounds.south - pad, - north: bounds.north + pad, - }; - if (kind === 'sequence') { - // Newest first; the per-tile cap applies to what passes the filter. - entry.sequenceList = decoded.sequences.sort( - (a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), - ); - entry.sequences = new Map(); - entry.total = decoded.sequences.length; - } else { - entry.points = decoded.overview; - entry.sequences = new Map(); - entry.total = decoded.overview.length; - } - attachPrimitive(entry); - state.coverage.tiles.set(key, entry); - state.coverage.lastError = null; - requestRender(); - } catch (error) { - if ( - controller.signal.aborted || - state.coverage.pending.get(key) !== controller - ) - return; - state.coverage.lastError = error?.message || 'Coverage tile failed'; - if (error?.keyRequired) state.keyRequired = true; - if (error?.keyRejected) { - // Every other tile would be refused too: stop asking. - state.keyRejected = true; - state.coverage.lastError = KEY_REJECTED_MESSAGE; - } - if (error?.retryAfterSec) holdFor(error.retryAfterSec); - } finally { - // Only the request that still owns the key settles it: a superseded one - // must not drop a newer request's entry (which is what counts as loading). - if (state.coverage.pending.get(key) === controller) { - state.coverage.pending.delete(key); - if (!state.coverage.pending.size) purgeStale(); - } - notify(); - } - } - - /** - * Mapillary is rate-limiting: keep what is drawn, request nothing until the - * wait is over, then refresh once. - */ - function holdFor(seconds) { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdUntil = Date.now() + seconds * 1000; - state.coverage.lastError = RATE_LIMITED_MESSAGE; - state.coverage.holdTimer = setTimeout(() => { - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - if (state.coverage.lastError === RATE_LIMITED_MESSAGE) - state.coverage.lastError = null; - refresh(); - }, seconds * 1000); - } - - /** Forget refusals when the provider goes off, so the next run asks again. */ - function resetErrors() { - clearTimeout(state.coverage.holdTimer); - state.coverage.holdTimer = null; - state.coverage.holdUntil = 0; - state.coverage.lastError = null; - state.keyRejected = false; - } - - /** Drop the previous zoom's tiles once the new ones are on screen. */ - function purgeStale() { - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = null; - if (!state.coverage.stale.size) return; - for (const entry of state.coverage.stale.values()) detachPrimitive(entry); - state.coverage.stale.clear(); - requestRender(); - } - - /** Move tiles to the stale set: the old zoom shows until the new one loads. */ - function retire() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const [key, entry] of state.coverage.tiles) { - cancelCast(entry); - const previous = state.coverage.stale.get(key); - if (previous) detachPrimitive(previous); - state.coverage.stale.set(key, entry); - } - state.coverage.tiles.clear(); - clearTimeout(state.coverage.staleTimer); - state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); - } - - function notify() { - state.context.notify(); - } - - /** - * Whether the screen centre meets the ground further ahead than the camera - * is high (a view tilted above 45°), or misses it. - */ - function isTilted({ nadir, ahead }, height) { - if (!ahead) return true; - return metresBetween(nadir, ahead) > Math.max(1, height); - } - - /** Recompute the tile set for the current camera and reconcile primitives. */ - function refresh() { - const viewer = state.viewer; - if (!viewer || !state.context.isActive() || state.keyRequired) return; - if (state.statusKnown === false) return; - if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; - const ground = groundUnderCamera(viewer, { - groundAt: state.context.groundCaster?.groundAt, - }); - const cameraHeight = viewer.camera?.positionCartographic?.height; - const height = Number.isFinite(cameraHeight) - ? cameraHeight - (ground ?? 0) - : null; - const sequenceZoom = coverageZoomForHeight(height); - const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); - const zoom = sequenceZoom ?? overviewZoom; - const kind = sequenceZoom ? 'sequence' : 'overview'; - // At street zooms the rays meet the ground where it really is (1,600 m up - // in Denver; Google 3D hides the globe) and stop short of the horizon. - const ranged = - kind === 'sequence' - ? { - groundHeight: ground, - maxRange: Math.max( - SEQUENCE_VIEW_RANGE_MIN_M, - height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, - ), - } - : {}; - // Tilted street views rank tiles along the line of sight, from the ground - // under the camera to the ground at the centre of the screen. - const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; - let bbox = visibleBbox(viewer, { - ...ranged, - // A tilted view's screen rows jump from the first metres to the horizon: - // keep the ground around the camera too. Looking down needs no help. - nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null, - }); - if (kind === 'overview' && (!bbox || zoom <= 1)) - bbox = [-180, -85, 180, 85]; - if (zoom == null || !bbox) { - state.coverage.hint = - 'Point the camera at the globe for street-level coverage'; - clear(); - notify(); - return; - } - state.coverage.hint = ''; - if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { - retire(); - state.coverage.zoom = zoom; - state.coverage.kind = kind; - } - const { tiles } = tilesForBbox(bbox, zoom, { - limit: - kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, - focus: focus && { from: focus.nadir, to: focus.ahead }, - }); - const wanted = new Set(tiles.map(tileKey)); - for (const key of [...state.coverage.tiles.keys()]) - if (!wanted.has(key)) removeTile(key); - for (const [key, controller] of [...state.coverage.pending]) - if (!wanted.has(key)) { - // The aborted request no longer owns its key, so its `finally` will - // not settle it: settle it here. - controller.abort(); - state.coverage.pending.delete(key); - } - for (const tile of tiles) loadTile(tile, kind); - if (!state.coverage.pending.size) purgeStale(); - // The camera moved: cells that were out of the sampler's range may not be, - // and lines left draped by a failed, partial or dropped cast get another try. - if (terrainMode()) - for (const entry of state.coverage.tiles.values()) { - if (entry.castRequested) requestMesh(entry); - if (entry.drapedLines) castTile(entry, { retry: true }); - } - notify(); - } - - function scheduleRefresh() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = setTimeout( - refresh, - COVERAGE_MOVE_DEBOUNCE_MS, - ); - } - - function attach(viewer) { - detach(); - const remove = viewer.camera.changed.addEventListener(scheduleRefresh); - const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); - state.coverage.removeCameraListener = () => { - remove(); - removeEnd(); - }; - refresh(); - } - - function detach() { - clearTimeout(state.coverage.debounceTimer); - state.coverage.debounceTimer = null; - state.coverage.removeCameraListener?.(); - state.coverage.removeCameraListener = null; - } - - function clear() { - for (const controller of state.coverage.pending.values()) - controller.abort(); - state.coverage.pending.clear(); - for (const key of [...state.coverage.tiles.keys()]) removeTile(key); - purgeStale(); - state.coverage.zoom = null; - state.coverage.kind = null; - clearTimeout(state.coverage.remeshTimer); - state.coverage.remeshTimer = null; - state.coverage.remeshDirty.clear(); - stopSelectionWatch(); - horizon.stop(); - requestRender(); - } - - /** Rebuild every loaded tile from its decoded cache (after a filter change). */ - function rebuild() { - purgeStale(); - for (const entry of state.coverage.tiles.values()) { - cancelCast(entry); - entry.castRequested = false; - entry.meshParts = null; - detachPrimitive(entry); - attachPrimitive(entry); - } - requestRender(); - notify(); - } - - /** - * Redraw tiles draped or cast for the new surface mode, then re-pick the - * zoom, since the ground height under the camera may have changed. - */ - function setSurface() { - if (!state.context.isActive()) return; - rebuild(); - refresh(); - } - - /** Recolour every part of a sequence in one ready primitive. */ - function recolorInstances(primitive, sequence, selected) { - const value = sequenceColor({ selected }); - for (const instanceId of partIds(sequence)) { - try { - const attributes = primitive.getGeometryInstanceAttributes(instanceId); - if (attributes) - attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( - value, - attributes.color, - ); - } catch { - /* instance not in this primitive */ - } - } - } - - /** Every tile on the globe: the current zoom's and the stale zoom's. */ - function drawnEntries() { - return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; - } - - /** A tile's primitives on the globe: its own, and the ones a swap still shows. */ - function drawnRecords(entry) { - const records = entry.primitives || []; - return entry.swap ? [...records, ...entry.swap.old] : records; - } - - /** Recolour one sequence, every part of it, in place (selection highlight). */ - function recolorSequence(id, selected) { - for (const entry of drawnEntries()) { - const sequence = entry.sequences.get(id); - if (!sequence) continue; - for (const record of drawnRecords(entry)) { - // Still building: `syncSelection` catches it up once it is ready. - if (!record.primitive.ready) continue; - recolorInstances(record.primitive, sequence, selected); - if (selected) record.selectedId = id; - else if (record.selectedId === id) record.selectedId = null; - } - } - requestRender(); - } - - /** - * Bring a ready primitive's highlight up to the current selection, when - * it was built with another one (or none). True when it changed. - */ - function syncSelection(entry, record) { - const current = state.sequence.selectedId ?? null; - if (record.selectedId === current || !record.primitive.ready) return false; - const previous = - record.selectedId && entry.sequences.get(record.selectedId); - if (previous) recolorInstances(record.primitive, previous, false); - const next = current && entry.sequences.get(current); - if (next) recolorInstances(record.primitive, next, true); - record.selectedId = current; - return true; - } - - /** - * A primitive cannot be recoloured until it is ready, so while any is - * building, catch each one up with the selection as it becomes ready. - */ - function watchSelection() { - const scene = state.viewer?.scene; - if (state.coverage.stopSelectionWatch || !scene?.postRender) return; - const stop = scene.postRender.addEventListener(() => { - let building = false; - let changed = false; - for (const entry of drawnEntries()) { - for (const record of entry.primitives || []) { - if (!record.primitive.ready) building = true; - else if (syncSelection(entry, record)) changed = true; - } - // Lines a swap still shows were ready (or never show): catch up only. - for (const record of entry.swap?.old || []) - if (syncSelection(entry, record)) changed = true; - } - if (changed) requestRender(); - if (!building) stopSelectionWatch(); - }); - state.coverage.stopSelectionWatch = stop; - } - - function stopSelectionWatch() { - state.coverage.stopSelectionWatch?.(); - state.coverage.stopSelectionWatch = null; - } - - /** - * Sequences (or overview points) drawn. A sequence that crosses a tile - * edge is in both tiles' lists, so sequences count once by id. - */ - function sequenceCount() { - const ids = new Set(); - let points = 0; - for (const entry of state.coverage.tiles.values()) - if (entry.kind === 'sequence') - for (const id of entry.sequences.keys()) ids.add(id); - else points += entry.count; - return ids.size + points; - } - - return { - attach, - detach, - refresh, - clear, - resetErrors, - rebuild, - setSurface, - recolorSequence, - sequenceCount, - }; } diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 6c038566e..c55c0d26e 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -55,8 +55,6 @@ export const COVERAGE_LINE_WIDTH_PX = 2.5; /** Per-sequence image cones after a sequence is selected. */ export const SEQUENCE_IMAGES_LIMIT = 2000; -export const IMAGE_CONE_SIZE_PX = 26; -export const IMAGE_CONE_MIN_SPACING_M = 3; /** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ export const NEAREST_LIMIT = 8; diff --git a/src/layers/streetLevel/providers/mapillary/sequences.js b/src/layers/streetLevel/providers/mapillary/sequences.js index 4be1f54a2..b10902a2b 100644 --- a/src/layers/streetLevel/providers/mapillary/sequences.js +++ b/src/layers/streetLevel/providers/mapillary/sequences.js @@ -1,20 +1,7 @@ -import * as Cesium from 'cesium'; -import { imageConeGlyph } from '../../glyphs.js'; -import { passesImageryFilter } from '../../filter.js'; -import { refineHeights } from '../../groundCast.js'; -import { createHorizonCull, metresBetween } from '../../view.js'; -import { meshCellKey } from '../../meshSampler.js'; -import { - COLORS, - IMAGE_CONE_MIN_SPACING_M, - IMAGE_CONE_SIZE_PX, - PICK_PREFIX, -} from './policy.js'; +import { createSequenceCones, thinImages } from '../../sequenceCones.js'; +import { COLORS, PICK_PREFIX } from './policy.js'; -const SPRITE_ID = 'street-level:mapillary-cones'; - -/** How many recently viewed sequences keep their image list in memory. */ -const SEQUENCE_CACHE_SIZE = 40; +export { thinImages } from '../../sequenceCones.js'; /** Normalize a graph image record into the shape the cones use. */ export function normalizeSequenceImage(record) { @@ -33,290 +20,24 @@ export function normalizeSequenceImage(record) { }; } -/** Drop images closer than the spacing to the previous kept one. */ -export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { - const kept = []; - let last = null; - for (const image of images) { - if (!last || metresBetween(last, image) >= spacingM) { - kept.push(image); - last = image; - } - } - return kept; -} - -/** Image cones for one selected sequence. */ +/** Image cones for the selected Mapillary sequence, its line highlighted. */ export function createSequences({ state, source, parts }) { - const { render, sprites } = state.services; - - function requestRender() { - render?.governorRequestRender?.('mapillary-sequence'); - } - - function notify() { - state.context.notify(); - } - - const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); - /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ - let drawn = nothingDrawn(); - - // The cones skip the depth test: hide the ones behind the globe. - const horizon = createHorizonCull({ - getViewer: () => state.viewer, - items: () => drawn.cones.map((cone) => cone.billboard), - onChange: requestRender, - }); - - function ensureCollections(viewer) { - if (state.sequence.collection) return; - state.sequence.collection = new Cesium.BillboardCollection({ - scene: viewer.scene, - }); - viewer.scene.primitives.add(state.sequence.collection); - sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection); - } - - function clearCones() { - state.sequence.collection?.removeAll(); - state.sequence.images = []; - drawn = nothingDrawn(); - horizon.stop(); - } - - /** Where a cone stands: at `height`, or clamped to the ground when null. */ - function placement(image, height) { - return { - position: Cesium.Cartesian3.fromDegrees( - image.lon, - image.lat, - height ?? 0, - ), - heightReference: - height === null - ? Cesium.HeightReference.CLAMP_TO_GROUND - : Cesium.HeightReference.NONE, - }; - } - - /** - * Terrain-mode cone heights on the street, not on roofs: bare earth refined - * by mesh samples. Null when draped or while terrain heights are fetched. - */ - function coneHeights(images, { request = true } = {}) { - const ground = state.context.groundCaster; - if (state.context.getSurface?.() !== 'terrain' || !ground) return null; - const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); - if (dems.includes(null)) { - ground - .prepare(images.map((image) => [image.lon, image.lat])) - .then((ready) => { - if (ready && state.sequence.images === images) renderCones(images); - }); - return null; - } - const sampler = state.context.meshSampler; - if (request) - sampler?.request(images.map((image) => [image.lon, image.lat])); - return refineHeights( - images.map((image, i) => ({ - dem: dems[i], - mesh: sampler?.meshAt(image.lon, image.lat), - })), - ); - } - - function renderCones(images) { - const collection = state.sequence.collection; - if (!collection) return; - collection.removeAll(); - drawn = { ...nothingDrawn(), images }; - const heights = coneHeights(images); - // One colour per source; 360° images keep the ring shape. - const cone = imageConeGlyph({ size: 32, color: COLORS.coverage }); - const ring = imageConeGlyph({ - size: 32, - color: COLORS.coverage, - pano: true, - }); - // Resolved now, so a "since N days" window keeps up with the clock. - const filter = state.context.getFilter(); - images.forEach((image, index) => { - if (!passesImageryFilter(image, filter)) return; - const height = heights?.[index] ?? null; - const billboard = collection.add({ - id: `${PICK_PREFIX.image}${image.id}`, - ...placement(image, height), - image: image.isPano ? ring : cone, - imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', - width: IMAGE_CONE_SIZE_PX, - height: IMAGE_CONE_SIZE_PX, - rotation: -Cesium.Math.toRadians(image.compassAngle), - alignedAxis: Cesium.Cartesian3.UNIT_Z, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), - }); - drawn.cones.push({ index, billboard, height }); - drawn.cells.add(meshCellKey(image.lon, image.lat)); - }); - horizon.update(); - requestRender(); - } - - /** - * Re-place cones whose height new mesh samples changed. A sample can move - * a neighbour too, so every height is recomputed but only moved cones touched. - */ - function onMeshSampled(batch) { - if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) - return; - if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) - return; - const { images, cones } = drawn; - // Already asked for when drawn; a missing terrain cell redraws them all. - const heights = coneHeights(images, { request: false }); - if (!heights) return; - const moved = []; - for (const cone of cones) { - const height = heights[cone.index] ?? null; - if (height === cone.height) continue; - cone.height = height; - const { position, heightReference } = placement( - images[cone.index], - height, - ); - cone.billboard.heightReference = heightReference; - cone.billboard.position = position; - moved.push(cone.billboard); - } - if (!moved.length) return; - horizon.update(moved); - requestRender(); - } - - function remember(sequenceId, images) { - const { cache } = state.sequence; - cache.delete(sequenceId); - cache.set(sequenceId, images); - while (cache.size > SEQUENCE_CACHE_SIZE) - cache.delete(cache.keys().next().value); - } - - /** Select a sequence: highlight its line and load its image cones. */ - async function select(sequenceId) { - if (!sequenceId || !state.viewer) return; - // Already on screen, or already on its way. - if ( - state.sequence.selectedId === sequenceId && - (state.sequence.images.length || state.sequence.abort) - ) - return; - if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.abort?.abort(); - state.sequence.selectedId = sequenceId; - parts.coverage.recolorSequence(sequenceId, true); - const cached = state.sequence.cache.get(sequenceId); - if (cached) { - state.sequence.abort = null; - state.sequence.loading = false; - state.sequence.images = cached; - renderCones(cached); - state.context.actions.reportError(null); - notify(); - return; - } - // The previous sequence's cones must not stay clickable under the new - // highlight while this one loads, nor after its load fails. - clearCones(); - requestRender(); - const controller = new AbortController(); - state.sequence.abort = controller; - state.sequence.loading = true; - notify(); - try { - const records = await source.getSequenceImages(sequenceId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - const images = thinImages( + return createSequenceCones({ + state, + async loadImages(sequenceId, { signal }) { + const records = await source.getSequenceImages(sequenceId, { signal }); + return thinImages( records .map(normalizeSequenceImage) .filter(Boolean) .sort((a, b) => a.capturedAt - b.capturedAt), ); - remember(sequenceId, images); - state.sequence.images = images; - renderCones(images); - state.context.actions.reportError(null); - } catch (error) { - if (!controller.signal.aborted) { - // Nothing to show: drop the highlight, so a click asks again. - parts.coverage.recolorSequence(sequenceId, false); - state.sequence.selectedId = null; - state.context.actions.reportError( - error?.message || 'Sequence images unavailable', - ); - } - } finally { - if (state.sequence.abort === controller) { - state.sequence.loading = false; - state.sequence.abort = null; - } - notify(); - } - } - - function clearSelection() { - state.sequence.abort?.abort(); - state.sequence.abort = null; - if (state.sequence.selectedId) - parts.coverage.recolorSequence(state.sequence.selectedId, false); - state.sequence.selectedId = null; - state.sequence.loading = false; - clearCones(); - // An error about the sequence goes with it. - state.context.actions.reportError(null); - requestRender(); - notify(); - } - - /** Re-draw the current sequence's cones (after an imagery filter change). */ - function rerender() { - if (state.sequence.images.length) renderCones(state.sequence.images); - } - - // New mesh samples may move the cones onto (or off) the road surface. - const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); - - function setVisible(visible) { - if (state.sequence.collection) state.sequence.collection.show = visible; - requestRender(); - } - - function destroy(viewer) { - state.sequence.abort?.abort(); - horizon.stop(); - drawn = nothingDrawn(); - stopMeshListener?.(); - const collection = state.sequence.collection; - if (collection) { - sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection); - viewer?.scene?.primitives?.remove(collection); - state.sequence.collection = null; - } - state.sequence.images = []; - state.sequence.selectedId = null; - } - - return { - ensureCollections, - select, - clearSelection, - rerender, - setVisible, - destroy, - }; + }, + colors: COLORS, + pickPrefix: PICK_PREFIX.image, + spriteId: 'street-level:mapillary-cones', + renderKey: 'mapillary-sequence', + onHighlight: (sequenceId, selected) => + parts.coverage.recolorSequence(sequenceId, selected), + }); } diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index a9ed18331..1e83ee7d4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -22,6 +22,7 @@ export function filtersOf(provider) { * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). + * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -32,7 +33,7 @@ export function filtersOf(provider) { * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. - * @property {{openImage: (imageId: string) => Promise, reportError: (message: string|null) => void}} actions + * @property {{openImage: (imageId: string) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions * * @typedef {object} ProviderInstance * @property {() => Promise<{configured: boolean}>} status @@ -44,7 +45,7 @@ export function filtersOf(provider) { * @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`). * @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode. * @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats - * @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix. + * @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground. * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats] diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index 429608449..e3710dd8e 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -28,7 +28,24 @@ export function createSelection({ state, parts }) { } const entry = state.providers.get(route.providerId); if (!entry?.on) return; - route.instance.handlePick(route.id); + route.instance.handlePick(route.id, { + groundPoint: () => groundPointAt(viewer, click.position), + }); + } + + /** Lon/lat of the ground under a screen point, or null (sky, no picker). */ + function groundPointAt(viewer, screenPosition) { + const position = state.services.scenePick?.pickGroundPosition?.( + viewer, + screenPosition, + ); + const carto = position && Cesium.Cartographic.fromCartesian(position); + return carto + ? { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + } + : null; } /** diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index f35cd4361..72e9e6e0c 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -8,7 +8,12 @@ import { createSelection } from './selection.js'; * `frame()` runs them; timers and Date.now() are mocked so a burst of moves * has a known timeline. */ -function harness({ selected = false } = {}) { +function harness({ + selected = false, + router = null, + providers = new Map(), + services = {}, +} = {}) { const saved = { document: globalThis.document, requestAnimationFrame: globalThis.requestAnimationFrame, @@ -60,12 +65,16 @@ function harness({ selected = false } = {}) { viewer, enabled: true, clickHandler: null, - services: { picking: null, input: null }, + providers, + services: { picking: null, input: null, ...services }, }; const selection = createSelection({ state, parts: { - router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, + router: router || { + ownsPick: (id) => id === 'mly:seq:1', + resolve: () => null, + }, hasSelectedSequence: () => sequences.selected, openAtGround: (position, picked) => groundClicks.push({ position, picked }), @@ -282,3 +291,43 @@ test('a click on nothing of ours goes to the ground handler with the pick, which h.restore(); } }); + +test('a click on a provider line hands it the ground under the click, measured only if asked', () => { + const handled = []; + const instance = { + handlePick: (id, click) => handled.push({ id, click }), + }; + const groundPicks = []; + const h = harness({ + router: { + ownsPick: () => true, + resolve: (id) => ({ providerId: 'kartaview', instance, id }), + }, + providers: new Map([['kartaview', { on: true }]]), + services: { + scenePick: { + pickGroundPosition: (viewer, position) => { + groundPicks.push(position); + return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190); + }, + }, + }, + }); + try { + h.scene.under = 'kv:line:14/1/2/3'; + h.click(40); + assert.equal(handled.length, 1); + assert.equal(handled[0].id, 'kv:line:14/1/2/3'); + assert.equal( + groundPicks.length, + 0, + 'no depth read until the provider asks', + ); + const point = handled[0].click.groundPoint(); + assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); + assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); + assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); + } finally { + h.restore(); + } +}); diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js new file mode 100644 index 000000000..a2aeca1ce --- /dev/null +++ b/src/layers/streetLevel/sequenceCones.js @@ -0,0 +1,331 @@ +import * as Cesium from 'cesium'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter } from './filter.js'; +import { refineHeights } from './groundCast.js'; +import { createHorizonCull, metresBetween } from './view.js'; +import { meshCellKey } from './meshSampler.js'; + +/** How many recently viewed sequences keep their image list in memory. */ +const SEQUENCE_CACHE_SIZE = 40; + +/** Cones closer than this to the previous kept one are dropped. */ +export const IMAGE_CONE_MIN_SPACING_M = 3; +export const IMAGE_CONE_SIZE_PX = 26; + +/** + * @typedef {object} ConeImage One image of a sequence, as the cones draw it. + * @property {string} id + * @property {number} lon + * @property {number} lat + * @property {number} compassAngle Degrees clockwise from north. + * @property {number} capturedAt Epoch ms (0 when unknown). + * @property {boolean} isPano + */ + +/** Drop images closer than the spacing to the previous kept one. */ +export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { + const kept = []; + let last = null; + for (const image of images) { + if (!last || metresBetween(last, image) >= spacingM) { + kept.push(image); + last = image; + } + } + return kept; +} + +/** + * Image cones for one selected sequence, any provider's. A cone's pick id is + * `pickPrefix` + the image id; 360° images draw as rings. + * + * @param {object} options + * @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`. + * @param {(sequenceId: string, init: {signal: AbortSignal}) => Promise>} options.loadImages + * The sequence's images in capture order, thinned (see `thinImages`). + * @param {{coverage: string}} options.colors + * @param {string} options.pickPrefix Pick-id prefix of the cones. + * @param {string} options.spriteId Sprite-order id of the cone collection. + * @param {string} options.renderKey Render-governor reason. + * @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight] + * Highlight (or stop highlighting) the sequence's coverage line. + */ +export function createSequenceCones({ + state, + loadImages, + colors, + pickPrefix, + spriteId, + renderKey, + onHighlight = () => {}, +}) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.(renderKey); + } + + function notify() { + state.context.notify(); + } + + const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); + /** Drawn cones, plus the mesh cells under them, so a sample moves only those. */ + let drawn = nothingDrawn(); + + // The cones skip the depth test: hide the ones behind the globe. + const horizon = createHorizonCull({ + getViewer: () => state.viewer, + items: () => drawn.cones.map((cone) => cone.billboard), + onChange: requestRender, + }); + + function ensureCollections(viewer) { + if (state.sequence.collection) return; + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection); + } + + function clearCones() { + state.sequence.collection?.removeAll(); + state.sequence.images = []; + drawn = nothingDrawn(); + horizon.stop(); + } + + /** Where a cone stands: at `height`, or clamped to the ground when null. */ + function placement(image, height) { + return { + position: Cesium.Cartesian3.fromDegrees( + image.lon, + image.lat, + height ?? 0, + ), + heightReference: + height === null + ? Cesium.HeightReference.CLAMP_TO_GROUND + : Cesium.HeightReference.NONE, + }; + } + + /** + * Terrain-mode cone heights on the street, not on roofs: bare earth refined + * by mesh samples. Null when draped or while terrain heights are fetched. + */ + function coneHeights(images, { request = true } = {}) { + const ground = state.context.groundCaster; + if (state.context.getSurface?.() !== 'terrain' || !ground) return null; + const dems = images.map((image) => ground.groundAt(image.lon, image.lat)); + if (dems.includes(null)) { + ground + .prepare(images.map((image) => [image.lon, image.lat])) + .then((ready) => { + if (ready && state.sequence.images === images) renderCones(images); + }); + return null; + } + const sampler = state.context.meshSampler; + if (request) + sampler?.request(images.map((image) => [image.lon, image.lat])); + return refineHeights( + images.map((image, i) => ({ + dem: dems[i], + mesh: sampler?.meshAt(image.lon, image.lat), + })), + ); + } + + function renderCones(images) { + const collection = state.sequence.collection; + if (!collection) return; + collection.removeAll(); + drawn = { ...nothingDrawn(), images }; + const heights = coneHeights(images); + // One colour per source; 360° images keep the ring shape. + const cone = imageConeGlyph({ size: 32, color: colors.coverage }); + const ring = imageConeGlyph({ + size: 32, + color: colors.coverage, + pano: true, + }); + // Resolved now, so a "since N days" window keeps up with the clock. + const filter = state.context.getFilter(); + images.forEach((image, index) => { + if (!passesImageryFilter(image, filter)) return; + const height = heights?.[index] ?? null; + const billboard = collection.add({ + id: `${pickPrefix}${image.id}`, + ...placement(image, height), + image: image.isPano ? ring : cone, + imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, + width: IMAGE_CONE_SIZE_PX, + height: IMAGE_CONE_SIZE_PX, + rotation: -Cesium.Math.toRadians(image.compassAngle), + alignedAxis: Cesium.Cartesian3.UNIT_Z, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + }); + drawn.cones.push({ index, billboard, height }); + drawn.cells.add(meshCellKey(image.lon, image.lat)); + }); + horizon.update(); + requestRender(); + } + + /** + * Re-place cones whose height new mesh samples changed. A sample can move + * a neighbour too, so every height is recomputed but only moved cones touched. + */ + function onMeshSampled(batch) { + if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) + return; + if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat)))) + return; + const { images, cones } = drawn; + // Already asked for when drawn; a missing terrain cell redraws them all. + const heights = coneHeights(images, { request: false }); + if (!heights) return; + const moved = []; + for (const cone of cones) { + const height = heights[cone.index] ?? null; + if (height === cone.height) continue; + cone.height = height; + const { position, heightReference } = placement( + images[cone.index], + height, + ); + cone.billboard.heightReference = heightReference; + cone.billboard.position = position; + moved.push(cone.billboard); + } + if (!moved.length) return; + horizon.update(moved); + requestRender(); + } + + function remember(sequenceId, images) { + const { cache } = state.sequence; + cache.delete(sequenceId); + cache.set(sequenceId, images); + while (cache.size > SEQUENCE_CACHE_SIZE) + cache.delete(cache.keys().next().value); + } + + /** + * Select a sequence: highlight its line and load its image cones. `force` + * reloads a selected one (a provider that loads part of a sequence moved on). + */ + async function select(sequenceId, { force = false } = {}) { + if (!sequenceId || !state.viewer) return; + // Already on screen, or already on its way. + if ( + !force && + state.sequence.selectedId === sequenceId && + (state.sequence.images.length || state.sequence.abort) + ) + return; + if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) + onHighlight(state.sequence.selectedId, false); + state.sequence.abort?.abort(); + state.sequence.selectedId = sequenceId; + onHighlight(sequenceId, true); + const cached = force ? null : state.sequence.cache.get(sequenceId); + if (cached) { + state.sequence.abort = null; + state.sequence.loading = false; + state.sequence.images = cached; + renderCones(cached); + state.context.actions.reportError(null); + notify(); + return; + } + // The previous sequence's cones must not stay clickable under the new + // highlight while this one loads, nor after its load fails. + clearCones(); + requestRender(); + const controller = new AbortController(); + state.sequence.abort = controller; + state.sequence.loading = true; + notify(); + try { + const images = await loadImages(sequenceId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + remember(sequenceId, images); + state.sequence.images = images; + renderCones(images); + state.context.actions.reportError(null); + } catch (error) { + if (!controller.signal.aborted) { + // Nothing to show: drop the highlight, so a click asks again. + onHighlight(sequenceId, false); + state.sequence.selectedId = null; + state.context.actions.reportError( + error?.message || 'Sequence images unavailable', + ); + } + } finally { + if (state.sequence.abort === controller) { + state.sequence.loading = false; + state.sequence.abort = null; + } + notify(); + } + } + + function clearSelection() { + state.sequence.abort?.abort(); + state.sequence.abort = null; + if (state.sequence.selectedId) + onHighlight(state.sequence.selectedId, false); + state.sequence.selectedId = null; + state.sequence.loading = false; + clearCones(); + // An error about the sequence goes with it. + state.context.actions.reportError(null); + requestRender(); + notify(); + } + + /** Re-draw the current sequence's cones (after an imagery filter change). */ + function rerender() { + if (state.sequence.images.length) renderCones(state.sequence.images); + } + + // New mesh samples may move the cones onto (or off) the road surface. + const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled); + + function setVisible(visible) { + if (state.sequence.collection) state.sequence.collection.show = visible; + requestRender(); + } + + function destroy(viewer) { + state.sequence.abort?.abort(); + horizon.stop(); + drawn = nothingDrawn(); + stopMeshListener?.(); + const collection = state.sequence.collection; + if (collection) { + sprites?.unregisterSpriteCollection?.(spriteId, collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence.collection = null; + } + state.sequence.images = []; + state.sequence.selectedId = null; + } + + return { + ensureCollections, + select, + clearSelection, + rerender, + setVisible, + destroy, + }; +} diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 4163d473f..77c6bf173 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -17,6 +17,7 @@ import { COLORS } from './policy.js'; * @property {string} color The source's one colour. * @property {boolean} [groundClick] * @property {Array<'pano'|'since'>} [filters] + * @property {{noun: string, action: string}|null} [coverageLabel] */ /** @@ -34,6 +35,8 @@ export function summarizeCoverage(providers) { ? 'No street-level imagery providers configured' : [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '), error: active.find((p) => p.error)?.error || null, + // One source is lit at a time; its wording counts the lines. + label: active.find((p) => p.coverageLabel)?.coverageLabel || null, keyRequired: active.length > 0 && active.every((p) => p.keyRequired === true), keyRejected: diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 894075f6f..fe0b19d82 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -154,6 +154,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot', loading: true, hint: 'Look down', error: 'boom', + label: null, keyRequired: false, keyRejected: false, }, diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 628633927..3a562c21e 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -39,6 +39,10 @@ export function createViewerHost({ state, parts }) { sequenceId: pose.sequenceId ?? null, creator: pose.creator || null, externalUrl: pose.externalUrl || null, + // FIT / FILL, unless this image has no frame to fit (a panorama). + renderModes: + typeof active.adapter.setRenderMode === 'function' && + active.adapter.supportsRenderModes?.() !== false, }); parts.marker.set( state.street.position, diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs new file mode 100644 index 000000000..4bd601e21 --- /dev/null +++ b/src/testSupport/kartaViewFakes.mjs @@ -0,0 +1,60 @@ +// DOM stand-ins for the KartaView viewer's tests (no browser in Node). + +/** A DOM element with what the viewer touches: children, events, attributes. */ +export function fakeElement(tag) { + const listeners = new Map(); + const element = { + tagName: tag.toUpperCase(), + children: [], + parentNode: null, + style: {}, + dataset: {}, + attributes: {}, + hidden: false, + disabled: false, + clientWidth: 400, + clientHeight: 250, + classList: { + values: new Set(), + add: (name) => element.classList.values.add(name), + remove: (name) => element.classList.values.delete(name), + }, + append(...nodes) { + for (const node of nodes) { + node.parentNode = element; + element.children.push(node); + } + }, + remove() { + const list = element.parentNode?.children; + if (list) list.splice(list.indexOf(element), 1); + element.parentNode = null; + }, + setAttribute: (name, value) => (element.attributes[name] = value), + removeAttribute: (name) => { + delete element.attributes[name]; + if (name === 'src') element.src = ''; + }, + addEventListener: (type, handler) => listeners.set(type, handler), + removeEventListener: (type) => listeners.delete(type), + setPointerCapture() {}, + /** Fire an event at the element's listener. */ + fire(type, event = {}) { + return listeners.get(type)?.({ + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() {}, + ...event, + }); + }, + }; + return element; +} + +export function fakeHost() { + const doc = { createElement: (tag) => fakeElement(tag) }; + const host = fakeElement('div'); + host.ownerDocument = doc; + return host; +} diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index ae5fe5ae3..16e32eec6 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js'; /** * Sites the app's browser code loads from directly: map imagery, 3D tiles, * terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its - * coverage tiles come through the app's server). Everything from the app's + * coverage tiles come through the app's server), Google Street View and + * KartaView. Everything from the app's * own server comes through the MCP server. */ const PROVIDER_ORIGINS = Object.freeze([ @@ -44,6 +45,10 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://*.ggpht.com', // Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net. 'https://*.xx.fbcdn.net', + // KartaView's API, its image proxy and its photo storage (storage1…storage13). + 'https://api.kartaview.org', + 'https://cdn.kartaview.org', + 'https://*.openstreetcam.org', ]); export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 6955e63b2..9ecfd077e 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -193,7 +193,7 @@ function presentMeta(state) { const hint = state.coverage.hint || ''; if (state.coverage.count > 0) return [ - `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`, + `${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`, hint, ] .filter(Boolean) diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 7f5b83cdc..302129b85 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -613,6 +613,86 @@ button.sl-status:focus-visible { font-size: 1.3rem; } +/* ── KartaView photo viewer ──────────────────────────────────────────────── */ +.sl-kv-viewer { + position: absolute; + inset: 0; + background: #000; + outline: none; + overflow: hidden; +} + +.sl-kv-viewer:focus-visible { + box-shadow: inset 0 0 0 2px var(--sl-kartaview, #635bff); +} + +.sl-kv-photo, +.sl-kv-pano { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + user-select: none; +} + +.sl-kv-photo { + object-fit: contain; +} + +.sl-kv-pano { + cursor: grab; + touch-action: none; +} + +.sl-kv-pano.sl-kv-dragging { + cursor: grabbing; +} + +.sl-kv-photo[hidden], +.sl-kv-pano[hidden] { + display: none; +} + +.sl-kv-step { + position: absolute; + top: 50%; + z-index: 2; + width: 34px; + height: 44px; + margin-top: -22px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(10, 10, 15, 0.62); + color: #fff; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; +} + +.sl-kv-step-prev { + left: 6px; +} + +.sl-kv-step-next { + right: 6px; +} + +.sl-kv-step:hover:not(:disabled), +.sl-kv-step:focus-visible { + background: rgba(99, 91, 255, 0.85); + outline: none; +} + +.sl-kv-step:disabled { + opacity: 0.3; + cursor: default; +} + +/* Nothing to step through until a photo shows. */ +.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step { + display: none; +} + /* ── Short viewports ─────────────────────────────────────────────────────── */ @media (max-height: 640px) { .street-level-panel-inner { From 310984ff0b3eb3ba724f32ed28e56167cd9eddef Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 18:42:36 +0100 Subject: [PATCH 19/78] test(street-level): KartaView in the hermetic browser gate; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture gate answers api.kartaview.org, cdn.kartaview.org and *.openstreetcam.org from fixtures (a drive with a 360° photo, stored on storage2 while the API names storage1) and adds three steps: keyless coverage with its credit and hidden filters, a line click opening the nearest photo with cones and ›, and a 360° photo on the panorama without FIT / FILL. The Street View share-link check reads the switch field instead of one exact entry, since a chip click records every provider's switch. README, DATA_SOURCES, CHANGELOG and CURRENT-STATE describe KartaView. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 + DATA_SOURCES.md | 1 + README.md | 10 +- docs/CURRENT-STATE.md | 42 ++- scripts/fixtures/street-level/kartaView.mjs | 186 +++++++++++++ scripts/format-scope.json | 1 + scripts/qa-street-level.mjs | 291 +++++++++++++++++++- src/tooling/streetLevelQa.test.mjs | 58 ++++ 8 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 scripts/fixtures/street-level/kartaView.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d15d897..a20a5efb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,18 @@ ## [Unreleased] +- Street Level: KartaView is the third provider, keyless. Light its chip + (purple; off by default, one provider at a time) and zoom in below about + 6 km: the lines are the streets KartaView has photos of (z13–14 tiles from + `api.kartaview.org`). A click on a line opens the photo nearest the click + without moving the camera, flat or 360° (a small WebGL panorama: drag to + look, wheel to zoom), with cones along its drive and ‹ › / ← → to step + through it. The caption links to the full-size photo (KartaView's photo + pages fail for signed-out visitors), and the globe credits "© KartaView + contributors, CC BY-SA 4.0". Share links carry it as `r.kartaview-1`. + Mapillary's line coverage and sequence cones are now shared by both + (`lineCoverage.js`, `sequenceCones.js`). + - Street Level: Google Street View is the second provider. On the Google 3D map (Google's terms keep it off other maps), light its chip (off by default: every panorama is billed to the Google Maps key, which needs the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 040e53afc..23d9cfc82 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -22,6 +22,7 @@ How to read this: | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | +| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index 9ce8d1ff0..0655a7c54 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,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 and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 Mapillary token · 🔴 Google Maps key | +| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟡 Mapillary token · 🔴 Google Maps key · KartaView keyless | | 📻 **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 | 🟢 | @@ -340,13 +340,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ## 📷 Street Level -> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token) and **Google Street View** (your Google Maps key). +> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key) and **KartaView** (no key). -**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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. +**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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**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), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google"). +**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), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -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.r.google-1` Street View on, `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`. +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.r.google-1` Street View on, `0.r.kartaview-1` KartaView on, `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 a9aeeddbc..e984d0ee3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3083,8 +3083,8 @@ 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), `r` (the other providers' switches by id, -e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary and Google -Street View are registered. Without +e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google +Street View and KartaView are 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 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 @@ -3135,9 +3135,12 @@ Providers implement the contract in `src/layers/streetLevel/registry.js`. The core routes clicks by pick prefix, swaps viewer adapters in the one host, manages each provider's credit and fans the filter out to every provider. To 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`. With providers on different keys the +`src/app/layers/streetLevel.js` and add its modules to +`scripts/package-boundaries.json`; its share switch travels in `r` by id. +A provider with line coverage builds it on `lineCoverage.js` (camera-driven +XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones +on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick +prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the layer's `requiresKeyId` is null and each chip reports its own key. Google Street View (`providers/google/`) runs on the Maps JavaScript API with @@ -3184,6 +3187,35 @@ once; a lookup or open Google never answers fails after 10 or 20 s. In CI shows off Google 3D, even switched on by a share link; the click and button step skips as `no Google key`. +KartaView (`providers/kartaview/`) is keyless and browser-side: CORS is open +on `api.kartaview.org` (the documented `api.openstreetcam.org` answers but +serves empty tiles), `cdn.kartaview.org` and `storage*.openstreetcam.org`. +Its chip starts off. Coverage comes from z14 JSON tiles below 2.5 km above +ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not +answer), so higher up the hint says to zoom in. A tile's lines are OSM way +segments with photos and carry no sequence, date or projection, so +`decode.js` chains segments that meet end to start (about a quarter of the +lines), they are drawn unselectable as `kv:line:`, and the provider +declares `filters: []` and `coverageLabel` ("N streets in view · click a line +for its nearest photo"). A click on a line reaches `handlePick(id, +{groundPoint})`, and the provider asks the core (`actions.openNearest`) for +its photo nearest that point within 50 m, without framing. The open photo's +sequence draws as cones from the 1,000-photo page holding it +(`/2.0/sequence/{id}/photos`; a page with missing photos is found by stepping +back), and ‹ › step across pages, re-centring the cones. The photo API answers +429 to more than about three requests at once, so `source.js` keeps two in +flight and retries a 429 with backoff. Photo URLs are built from `storage` and +`filepath*`: the API's `fileurl*` always name storage1, where photos stored +elsewhere 404. The viewer (`viewer.js`) shows a flat photo's 1280 px rendition, +then a 2,560 px one from the image proxy (`pr:sharp/rs:fit:W:H/`), +and a 360° photo's equirectangular `wrapped_proc` image (13,000 px wide +upstream) resized to 1,024 then 4,096 px on `panorama.js`, a single-triangle +WebGL renderer (image centre = the camera heading; drag, wheel). Without WebGL +a 360° photo shows its front view. FIT / FILL hide for panoramas +(`supportsRenderModes`). The caption has no place name (KartaView's is where +the drive began) and links to the full-size photo, since kartaview.org's photo +pages fail for signed-out visitors. + Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs new file mode 100644 index 000000000..ccc7564e2 --- /dev/null +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -0,0 +1,186 @@ +import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; +import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; + +/** + * KartaView's API, image proxy and photo storage answered from fixtures, so + * the gate's KartaView steps need no network. One drive runs north along + * KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while + * their `fileurl*` name storage1, as KartaView's API does, so the gate also + * proves the provider builds addresses from `storage`. + */ + +export const KV_SEQUENCE_ID = '900001'; +export const KV_LINE = Object.freeze({ + lon: -121.4931, + south: 38.5795, + north: 38.5875, +}); +const KV_SPACING_M = 25; +const METRES_PER_DEG_LAT = 110_540; +const SHOT = '2024-05-02 16:20:00.000'; + +const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); + +/** The drive's photos, south to north; every tenth is 360°. */ +export function kartaViewPhotos() { + const step = KV_SPACING_M / METRES_PER_DEG_LAT; + const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1; + return Array.from({ length: count }, (_, i) => ({ + id: String(77_000_000 + i), + index: i, + lon: KV_LINE.lon, + lat: Number((KV_LINE.south + i * step).toFixed(7)), + isPano: i % 10 === 5, + })); +} + +function record(photo) { + const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`; + const folder = photo.isPano ? 'wrapped_proc' : 'proc'; + const path = (prefix) => `2024/5/2/${prefix}/${name}`; + const storage1 = (prefix) => + `https://storage1.openstreetcam.org/files/photo/${path(prefix)}`; + return { + id: photo.id, + sequenceId: KV_SEQUENCE_ID, + sequenceIndex: String(photo.index), + lat: String(photo.lat), + lng: String(photo.lon), + matchLat: String(photo.lat), + matchLng: String(photo.lon), + heading: '0', + fieldOfView: photo.isPano ? '360' : '0', + projection: photo.isPano ? 'SPHERE' : 'PLANE', + shotDate: SHOT, + dateAdded: SHOT, + storage: 'storage2', + filepath: path('{{sizeprefix}}'), + filepathLTh: path('lth'), + filepathProc: path(folder), + fileurl: storage1('{{sizeprefix}}'), + fileurlLTh: storage1('lth'), + fileurlProc: storage1(folder), + }; +} + +const ok = (result) => ({ + status: 200, + contentType: 'application/json', + headers: CORS, + body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }), +}); +const raw = (status, body) => ({ + status, + contentType: 'application/json', + headers: CORS, + body: JSON.stringify(body), +}); + +/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */ +function tileLines(z, x, y) { + const { west, east, south, north } = tileBounds(x, y, z); + if (KV_LINE.lon < west || KV_LINE.lon > east) return []; + const from = Math.max(KV_LINE.south, south); + const to = Math.min(KV_LINE.north, north); + if (from >= to) return []; + // Two segments meeting end to start, as KartaView splits ways. + const mid = (from + to) / 2; + const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) }); + return [ + { track: [at(from), at(mid)], matched: '1' }, + { track: [at(mid), at(to)], matched: '1' }, + ]; +} + +/** A call named by host, path shape and parameter names. */ +export function describeKartaViewCall(method, url) { + const keys = [...url.searchParams.keys()].sort(); + const path = url.pathname + .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') + .replace(/\/photo\/\d+$/, '/photo/{id}') + .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') + .replace(/^\/files\/photo\/.*$/, '/files/photo/{path}') + .replace( + /^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/, + '/pr:sharp/rs:fit:{w}:{h}/{address}', + ); + return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`; +} + +/** + * Answer one KartaView request; `known` is false for a call no fixture + * covers, so the gate fails on a new one instead of missing it. + */ +export function answerKartaViewRequest({ method, url }) { + const address = new URL(url); + const photos = kartaViewPhotos(); + if (method === 'OPTIONS') + return { status: 204, headers: CORS, body: '', known: true }; + const image = { + status: 200, + contentType: 'image/jpeg', + headers: CORS, + body: photoJpeg(), + known: true, + }; + if (address.hostname === 'cdn.kartaview.org') + return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname) + ? image + : { ...raw(404, {}), known: false }; + if (address.hostname === 'storage2.openstreetcam.org') + return address.pathname.includes(KV_SEQUENCE_ID) + ? image + : { ...raw(404, {}), known: false }; + if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname)) + // The wrong host the API names: KartaView answers 404 there. + return { ...raw(404, {}), known: false }; + if (address.hostname !== 'api.kartaview.org') + return { ...raw(404, {}), known: false }; + const path = address.pathname; + const tile = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/, + ); + if (tile) + return { + ...raw(200, { + lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])), + }), + known: true, + }; + if (path === '/2.0/photo/') { + const q = address.searchParams; + const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + const near = photos + .map((photo) => ({ photo, metres: metresApart(at, photo) })) + .filter(({ metres }) => metres < 200) + .sort((a, b) => a.metres - b.metres) + .slice(0, Number(q.get('itemsPerPage')) || 10) + .map(({ photo, metres }) => ({ + ...record(photo), + distance: metres.toFixed(2), + })); + return { ...ok({ data: near, hasMoreData: false }), known: true }; + } + const one = path.match(/^\/2\.0\/photo\/(\d+)$/); + if (one) { + const photo = photos.find((p) => p.id === one[1]); + return photo + ? { ...ok({ data: record(photo) }), known: true } + : { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true }; + } + const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/); + if (page) { + const q = address.searchParams; + const size = Number(q.get('itemsPerPage')) || 100; + const number = Number(q.get('page')) || 1; + const rows = page[1] === KV_SEQUENCE_ID ? photos : []; + return { + ...ok({ + data: rows.slice((number - 1) * size, number * size).map(record), + hasMoreData: number * size < rows.length, + }), + known: true, + }; + } + return { ...raw(404, {}), known: false }; +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 57fe34f7c..dbfa055ea 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -933,6 +933,7 @@ "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", + "scripts/fixtures/street-level/kartaView.mjs", "src/layers/streetLevel/filter.test.mjs", "src/layers/streetLevel/registry.test.mjs", "src/layers/streetLevel/params.test.mjs", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index a63836e0b..60cb7f379 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -3,8 +3,8 @@ * Browser QA for the Street Level layer against a running server. Run with * `npm run qa:street-level -- --url http://localhost:4173`. * - * `--fixtures` (the CI mode) answers every *.mapillary.com request from - * fixtures, so nothing reaches Mapillary. The server still needs a token + * `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView + * request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token * because the bundle carries it; CI uses a dummy one. Fixture runs also stage * a rejected key and a keyless second server. * @@ -37,6 +37,13 @@ import { PHOTO_SEQUENCE_ID, photoImages, } from './fixtures/street-level/mapillaryGraph.mjs'; +import { + answerKartaViewRequest, + describeKartaViewCall, + KV_LINE, + KV_SEQUENCE_ID, + kartaViewPhotos, +} from './fixtures/street-level/kartaView.mjs'; const PANEL_ID = 'street-level-panel'; @@ -49,7 +56,11 @@ export const VIEWPORTS = Object.freeze([ ]); /** Provider chips the panel must show, in order. */ -export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']); +export const EXPECTED_PROVIDERS = Object.freeze([ + 'mapillary', + 'google', + 'kartaview', +]); /** * Skips no setup can avoid: CI forks have no Google key (so neither Google 3D @@ -229,6 +240,9 @@ export async function serveFixtures(page, fixture) { patterns: [ { urlPattern: '*://*.mapillary.com/*' }, { urlPattern: '*/api/mapillary/*' }, + { urlPattern: '*://api.kartaview.org/*' }, + { urlPattern: '*://cdn.kartaview.org/*' }, + { urlPattern: '*://*.openstreetcam.org/*' }, ], }); const fulfil = (requestId, { status, headers = {}, contentType, body }) => @@ -259,6 +273,11 @@ export async function serveFixtures(page, fixture) { }); fixture.calls.add(call); if (!answer.known) fixture.unknown.push(call); + } else if (/(^|\.)(kartaview|openstreetcam)\.org$/.test(address.hostname)) { + const call = describeKartaViewCall(method, address); + answer = answerKartaViewRequest({ method, url: request.url }); + fixture.calls.add(call); + if (!answer.known) fixture.unknown.push(call); } else if ( address.pathname === '/api/mapillary/status' && fixture.configured !== undefined @@ -726,6 +745,253 @@ async function main() { `${count} steps: ${count - skips.length} ok, ${skips.length} skipped`, ); + /** + * KartaView: keyless coverage from its JSON tiles, a line click opening + * the nearest photo, cones along the drive, › and a 360° photo. Runs on a + * draped map so a line is where it is drawn; leaves Mapillary lit. + */ + async function kartaViewSteps() { + const ui = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState(), + ); + const uiUntil = (test, arg = null, options) => + settle( + page, + `(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`, + undefined, + options, + ); + const lit = (id) => + uiUntil( + (u, want) => + u.providers.find((p) => p.id === want)?.on === true && + u.providers.filter((p) => p.on).length === 1, + id, + ); + const stack = () => + page.evaluate(() => + window.__godsEyeView.mapStackController.getActiveId(), + ); + const setStack = async (id) => { + await page.evaluate( + (stackId) => + window.__godsEyeView.mapStackController.setStack(stackId), + id, + ); + await settle( + page, + (stackId) => + window.__godsEyeView.mapStackController.getActiveId() === stackId, + id, + ); + }; + const kvChip = '#sl-provider-chips [data-chip-id="kartaview"]'; + const original = await stack(); + const photos = kartaViewPhotos(); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + await setStack('esri-imagery'); + // Over the fixture drive, looking down: its line is mid-screen. + for (let attempt = 0; attempt < 4; attempt++) { + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + }, PARK); + await sleep(1000); + } + await step( + 'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters', + async () => { + await clickPanelControl(page, kvChip); + assert.ok(await lit('kartaview'), 'KartaView alone is lit'); + await waitForCoverage(page); + const u = await ui(); + assert.equal(u.keyRequired, false); + assert.deepEqual(u.filterControls, { pano: false, since: false }); + assert.equal( + await page.$eval(kvChip, (chip) => + chip.style.getPropertyValue('--chip-color'), + ), + '#635bff', + ); + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /streets in view · click a line for its nearest photo/, + ); + await page.waitForFunction( + () => + document.body.innerHTML.includes('KartaView contributors'), + { timeout: 15_000 }, + ); + assert.ok( + await settle(page, () => + (new URLSearchParams(location.hash.slice(1)).get('lo') || '') + .split('_') + .some( + (entry) => + entry.startsWith('0.') && entry.includes('kartaview-1'), + ), + ), + `the share link lights KartaView: ${await page.evaluate(() => location.hash)}`, + ); + }, + ); + await step( + 'KartaView: a click on a line opens the nearest photo, cones follow its drive, and › steps along it', + async ({ skip }) => { + const along = Array.from({ length: 9 }, (_, i) => [ + KV_LINE.lon, + KV_LINE.south + ((i + 2) / 13) * (KV_LINE.north - KV_LINE.south), + ]); + const handle = await page + .waitForFunction( + findPickPoint, + { timeout: 30_000, polling: 250 }, + { + prefix: 'kv:line:', + id: null, + points: fixtures ? along : null, + billboardIds: null, + }, + ) + .catch(() => null); + if (!handle && !fixtures) return skip('no KartaView line in view'); + assert.ok(handle, 'a KartaView line is pickable'); + const at = await handle.jsonValue(); + await page.mouse.click(at.x, at.y); + await page.waitForFunction( + () => { + const s = window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().street; + return (s.imageId && !s.loading) || s.error; + }, + { timeout: 60_000 }, + ); + const street = (await ui()).street; + if (!fixtures && /No street-level imagery/.test(street.error || '')) + return skip('no KartaView photo near the line'); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'kartaview'); + assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); + assert.ok( + await settle(page, () => { + const img = document.querySelector('.sl-kv-photo'); + return img && !img.hidden && img.naturalWidth > 0; + }), + 'the photo is on screen', + ); + if (fixtures) { + assert.ok( + photos.some((photo) => photo.id === street.imageId), + `${street.imageId} is a fixture photo`, + ); + assert.ok( + await uiUntil( + (u, want) => + u.sequence.selectedId === want.id && + !u.sequence.loading && + u.sequence.images === want.count, + { id: KV_SEQUENCE_ID, count: photos.length }, + { timeout: 30_000 }, + ), + `cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`, + ); + } + const before = street.imageId; + await clickPanelControl(page, '.sl-kv-step-next'); + assert.ok( + await uiUntil( + (u, was) => + u.street.imageId && u.street.imageId !== was && !u.street.error, + before, + { timeout: 30_000 }, + ), + '› opened the next photo', + ); + if (fixtures) + assert.equal( + (await ui()).street.imageId, + String(Number(before) + 1), + 'the next photo along the drive', + ); + }, + ); + if (fixtures) + await step( + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it', + async () => { + const pano = photos.find((photo) => photo.isPano); + await page.evaluate((id) => { + void window.__godsEyeView.dataManager.layers + .get('street-level') + .module.openImage('kartaview', id); + }, pano.id); + assert.ok( + await uiUntil( + (u, want) => + u.street.imageId === want && + !u.street.loading && + u.street.isPano, + pano.id, + { timeout: 60_000 }, + ), + `the panorama opened: ${JSON.stringify((await ui()).street)}`, + ); + const u = await ui(); + assert.equal(u.street.error, null); + assert.equal(u.street.renderModes, false, 'no frame to fit'); + assert.equal( + await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden), + false, + ); + const box = await page.$eval('.sl-kv-pano', (canvas) => { + const r = canvas.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + const bearing = u.street.bearing; + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) + await page.mouse.move(box.x - i * 15, box.y); + await page.mouse.up(); + assert.ok( + await uiUntil((v, was) => v.street.bearing !== was, bearing), + 'dragging the panorama turned its bearing', + ); + }, + ); + else + await skipStep( + 'fixtures only', + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it', + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + await clickPanelControl( + page, + '#sl-provider-chips [data-chip-id="mapillary"]', + ); + assert.ok(await lit('mapillary'), 'Mapillary is lit again'); + await setStack(original); + } + async function keyedSteps() { // A late startup flight only shows itself by moving the camera, so park // and check it stays put rather than reading a state. @@ -1803,16 +2069,24 @@ async function main() { credit: false, button: false, }); - // Street View rides the shared provider-switch field (r). + // Street View rides the shared provider-switch field (r), beside + // the other providers' switches a chip click recorded. const option = `${STREET_LEVEL_TOKEN}.r.google-1`; assert.ok( await settle( page, - (token) => + (prefix) => (new URLSearchParams(location.hash.slice(1)).get('lo') || '') .split('_') - .includes(token), - option, + .some( + (entry) => + entry.startsWith(prefix) && + entry + .slice(prefix.length) + .split('*') + .includes('google-1'), + ), + `${STREET_LEVEL_TOKEN}.r.`, // The link is written once the view rests for 500 ms; under a // software renderer with a photo open that can take a while. { timeout: 30_000 }, @@ -2026,6 +2300,7 @@ async function main() { } }, ); + await kartaViewSteps(); await step( 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', async () => { @@ -2108,7 +2383,7 @@ async function main() { ); if (fixtures) await step( - 'every Mapillary request was answered from a fixture', + 'every Mapillary and KartaView request was answered from a fixture', () => { console.log( ` (answered: ${[...fixture.calls].sort().join(' | ')})`, diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index e1e38bfb5..33ae4e4a0 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -20,6 +20,15 @@ import { THUMB_HOST, } from '../../scripts/fixtures/street-level/mapillaryGraph.mjs'; import { redact } from '../../scripts/qa-browserEvidence.mjs'; +import { + answerKartaViewRequest, + describeKartaViewCall, + KV_LINE, + KV_SEQUENCE_ID, + kartaViewPhotos, +} from '../../scripts/fixtures/street-level/kartaView.mjs'; +import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; +import { normalizePhoto } from '../layers/streetLevel/providers/kartaview/policy.js'; // The press helpers wait for paint; Node has no frames, so a tick stands in. globalThis.requestAnimationFrame ??= (callback) => @@ -639,3 +648,52 @@ test('CI runs the gates hermetically against a production build, strict, with ev 'a missed header press fails CI now that the rail settles', ); }); + +test('KartaView fixtures draw the drive, answer nearest first, and refuse the storage host the API names', () => { + const body = (answer) => JSON.parse(answer.body); + const lat = (KV_LINE.south + KV_LINE.north) / 2; + const x = lonToTileX(KV_LINE.lon, 14); + const y = latToTileY(lat, 14); + const tile = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/14.json`, + }); + assert.equal(tile.known, true); + assert.ok(body(tile).lines.length >= 1); + const empty = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`, + }); + assert.deepEqual(body(empty).lines, []); + const near = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`, + }); + const records = body(near).result.data; + assert.ok(records.length > 1); + assert.ok(Number(records[0].distance) <= Number(records[1].distance)); + const photo = normalizePhoto(records[0]); + assert.match(photo.urls.view, /^https:\/\/storage2\.openstreetcam\.org\//); + assert.equal( + answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known, + true, + ); + assert.equal( + answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known, + false, + 'the storage1 address the API gives is not where the photo is', + ); + const page = answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/sequence/${KV_SEQUENCE_ID}/photos?page=1&itemsPerPage=1000`, + }); + assert.equal(body(page).result.data.length, kartaViewPhotos().length); + assert.ok(kartaViewPhotos().some((p) => p.isPano)); + assert.equal( + describeKartaViewCall( + 'GET', + new URL('https://api.kartaview.org/2.0/photo/77000001'), + ), + 'GET api.kartaview.org/2.0/photo/{id}', + ); +}); From 454e2612562e46e8581f2267410031d85b81cd8d Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 19:32:18 +0100 Subject: [PATCH 20/78] =?UTF-8?q?fix(street-level):=20KartaView=20QA=20rou?= =?UTF-8?q?nd=20=E2=80=94=20archived=20photos,=20connection=20budget,=20pa?= =?UTF-8?q?ging,=20=E2=80=B9=20=E2=80=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From two agentic QA passes (UX and failure modes): - A line click opens the nearest photo that can be shown: archived photos (409; a HEAD says 200) and records without files are skipped, a few at most, else 'KartaView's photos here are archived'. ‹ › skip them too. - api.kartaview.org is HTTP/1.1 (six connections): tiles take three, lookups two, sequence pages one, so a lookup never times out behind tiles; a timed-out lookup is asked once more; waits are capped at 10 s. - Sequence pages follow photo ids, not the index: a photo's page is found by search, a neighbour page joins near an edge, so ‹ › neither skip at page edges nor loop past gaps. Pages are 500 photos (~1.2 MB). - ‹ › open through the core without framing: loading, errors and cones behave as for any open; presses step on from the photo on its way; the ends disable up front. - Cones ignore the hidden IMAGERY / SINCE filters; no 'Selected' swatch for a source whose lines never highlight; 'no KartaView photos in view'; an unreadable tile is not cached as empty; a 0,0 fix is no photo; a failed lookup's error goes when another source is lit; the link goes through the image proxy; a stalled panorama shows its front view after 15 s instead of 60. Co-Authored-By: Claude Opus 5.5 --- src/layers/streetLevel/index.js | 7 +- src/layers/streetLevel/index.test.mjs | 20 ++ .../streetLevel/providers/kartaview/decode.js | 5 +- .../providers/kartaview/decode.test.mjs | 14 +- .../streetLevel/providers/kartaview/index.js | 171 +++++++++++++----- .../streetLevel/providers/kartaview/policy.js | 36 +++- .../providers/kartaview/policy.test.mjs | 17 +- .../providers/kartaview/provider.test.mjs | 164 +++++++++++++---- .../streetLevel/providers/kartaview/source.js | 99 ++++++++-- .../providers/kartaview/source.test.mjs | 103 ++++++++++- .../streetLevel/providers/kartaview/viewer.js | 138 +++++++++----- .../providers/kartaview/viewer.test.mjs | 47 +++-- src/layers/streetLevel/registry.js | 5 +- src/layers/streetLevel/sequenceCones.js | 5 +- src/layers/streetLevel/uiState.js | 9 +- src/layers/streetLevel/uiState.test.mjs | 22 +++ 16 files changed, 683 insertions(+), 179 deletions(-) diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index c7170f05c..6384b9217 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -108,7 +108,9 @@ export function createStreetLevelLayer({ getSurface: () => state.surface, notify, actions: { - openImage: (imageId) => openImage(entry.def.id, imageId), + /** `{frame: false}` opens it without moving the camera. */ + openImage: (imageId, options) => + openImage(entry.def.id, imageId, options), /** This provider's image nearest a point the user picked on the map. */ openNearest: (point) => layer.openNearest(point, { @@ -345,6 +347,8 @@ export function createStreetLevelLayer({ for (const other of state.providers.values()) if (other !== entry && other.on) switchProvider(other, false); switchProvider(entry, next); + // A failed lookup's message was about the source just switched away. + if (!state.street.open) state.street.error = null; notify(); return true; } @@ -414,6 +418,7 @@ export function createStreetLevelLayer({ error: stats.error || null, color: entry.def.colors.coverage, coverageLabel: entry.def.coverageLabel || null, + highlightsSequence: entry.def.highlightsSequence !== false, groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 5ae19c2d0..ccc8b9dfe 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -818,3 +818,23 @@ test('an empty provider composition enables and tears down without acquiring ima layer.enable(viewer); assert.deepEqual(layer.getUIState().providers, []); }); + +test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => { + const failing = fakeProvider({ + nearestImage: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + }); + const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); + const { layer, viewer } = await enabledLayer(t, [failing, other]); + layer.attachViewerHost({}); + await layer.openNearest({ lat: 1, lon: 1 }); + assert.match(layer.getUIState().street.error, /did not answer/); + layer.setProviderEnabled('other', true); + await settle(); + assert.equal(layer.getUIState().street.error, null); + // A provider's own open (KartaView's ‹ ›) can leave the camera alone. + await other.context().actions.openImage('img-1', { frame: false }); + assert.deepEqual(other.calls.open, ['img-1']); + assert.equal(viewer.flights.started, 0); +}); diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index 2c8687eba..d670f4ca2 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -9,8 +9,11 @@ * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} */ export function decodeCoverageTile(payload, { x, y, z }) { + // Not cached as empty: an unreadable tile is asked for again. + if (!Array.isArray(payload?.lines)) + throw new Error('KartaView sent an unreadable coverage tile'); const segments = []; - for (const line of Array.isArray(payload?.lines) ? payload.lines : []) { + for (const line of payload.lines) { const points = []; for (const point of Array.isArray(line?.track) ? line.track : []) { const lon = Number(point?.lng); diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index c1ab57f3a..c84855e06 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -71,5 +71,17 @@ test('bad points, repeats and one-point tracks are skipped; an empty tile has no [2, 2], ]); assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []); - assert.deepEqual(decodeCoverageTile(null, TILE).sequences, []); +}); + +test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => { + for (const payload of [ + null, + {}, + { lines: 'nope' }, + { status: { apiCode: 690 } }, + ]) + assert.throws( + () => decodeCoverageTile(payload, TILE), + /unreadable coverage tile/, + ); }); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index c9da4f7c5..a056771dc 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -5,6 +5,7 @@ import { decodeCoverageTile } from './decode.js'; import { createKartaViewSource } from './source.js'; import { createKartaViewViewer } from './viewer.js'; import { + ARCHIVED_MESSAGE, COLORS, COVERAGE_MAX_HEIGHT_M, COVERAGE_NOUN, @@ -15,9 +16,12 @@ import { KARTAVIEW_NAME, KARTAVIEW_PROVIDER_ID, LINE_CLICK_HINT, + NEAREST_PROBES, NEAREST_RADIUS_M, + NO_COVERAGE_HINT, PICK_PREFIX, RATE_LIMITED_MESSAGE, + SEQUENCE_PAGE_EDGE, SEQUENCE_PAGE_SIZE, TOO_HIGH_HINT, normalizePhoto, @@ -85,6 +89,8 @@ export function createKartaViewProvider({ defaultOn: false, // Coverage lines carry no date or projection: no filter could thin them. filters: [], + // Its lines are streets, not sequences: a selected drive shows as cones. + highlightsSequence: false, coverageLabel: Object.freeze({ noun: COVERAGE_NOUN, action: LINE_CLICK_HINT, @@ -169,42 +175,94 @@ export function createKartaViewProvider({ return entry; } - /** The page holding `photo` (an earlier one when photos are missing). */ - async function windowOf(photo) { - let page = pageOf(photo.sequenceIndex ?? 0); - for (let tries = 0; tries < 3; tries++) { - const window = await loadPage(photo.sequenceId, page); - const first = window.photos[0]; - if ( - window.photos.some((p) => p.id === photo.id) || - page === 1 || - !first || - (first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0) - ) - return window; - page--; + /** + * The page holding `photo`. Pages follow photo ids, which only roughly + * follow the index, and photos go missing, so the page the index + * suggests is a first guess: step towards the photo until a page holds + * it, or the pages straddle where it would be. + */ + async function homePage(photo) { + let number = pageOf(photo.sequenceIndex ?? 0); + const index = photo.sequenceIndex ?? 0; + const seen = new Set(); + let page = null; + while (!seen.has(number) && seen.size < 6) { + seen.add(number); + page = await loadPage(photo.sequenceId, number); + if (page.photos.some((p) => p.id === photo.id)) return page; + const first = page.photos[0]?.sequenceIndex; + const last = page.photos.at(-1)?.sequenceIndex; + // Past the last page, or before this page's photos: go back. + if ((first === undefined || index < first) && number > 1) number--; + else if (last !== undefined && index > last && page.hasMore) number++; + else break; } - return loadPage(photo.sequenceId, page); + return page; } + /** + * The photos around `photo` in index order: its page, plus a + * neighbour page when it sits near an edge (pages overlap in index). + * @returns {Promise<{home: number, photos: Array}>} + */ + async function windowOf(photo) { + const home = await homePage(photo); + const list = home.photos; + const at = list.findIndex((p) => p.id === photo.id); + const parts = [list]; + if (at !== -1 && at < SEQUENCE_PAGE_EDGE && home.page > 1) + parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos); + if (at !== -1 && at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore) + parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos); + if (parts.length === 1) return { home: home.page, photos: list }; + const byId = new Map(parts.flat().map((p) => [p.id, p])); + return { + home: home.page, + photos: [...byId.values()].sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + }; + } + + /** Photo id → whether its file answers (archived ones do not), per session. */ + const probes = createLru(PHOTO_CACHE_SIZE); + function showable(photo, { signal } = {}) { + if (!photo?.urls?.view) return Promise.resolve(false); + let probe = probes.get(photo.id); + if (!probe) { + probe = source.probeImage(photo.urls.view, { signal }); + probes.set(photo.id, probe); + probe.catch(() => probes.delete(photo.id)); + } + return probe; + } + + /** + * The next showable photo `delta` along the sequence, or null at its + * end. Archived photos are skipped, a few at most. + */ async function step(photo, delta) { if (!photo?.sequenceId) return null; - const window = await windowOf(photo); - const list = window.photos; - let at = list.findIndex((p) => p.id === photo.id); - if (at === -1) - at = list.findIndex( - (p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0), - ); - const next = at === -1 ? -1 : at + delta; - if (next >= 0 && next < list.length) return list[next].id; - if (next < 0 && window.page > 1) { - const before = await loadPage(photo.sequenceId, window.page - 1); - return before.photos.at(-1)?.id ?? null; - } - if (next >= list.length && window.hasMore) { - const after = await loadPage(photo.sequenceId, window.page + 1); - return after.photos[0]?.id ?? null; + let from = photo; + for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) { + const { photos: list } = await windowOf(from); + let at = list.findIndex((p) => p.id === from.id); + if (at === -1) { + // Not found anywhere: the nearest index on the right side. + const index = from.sequenceIndex ?? 0; + const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index); + if (delta > 0 && later === -1) return null; + at = + delta > 0 + ? later - 1 + : later === -1 + ? list.length + : list.findIndex((p) => (p.sequenceIndex ?? 0) >= index); + } + const next = list[at + delta]; + if (!next) return null; + if (await showable(next)) return next.id; + from = next; } return null; } @@ -229,7 +287,7 @@ export function createKartaViewProvider({ if (!anchor) return []; const window = await windowOf(anchor); signal?.throwIfAborted(); - drawnPage = { sequenceId, page: window.page }; + drawnPage = { sequenceId, page: window.home }; return thinImages( window.photos.map((photo) => ({ id: photo.id, @@ -245,16 +303,24 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', + // The filters are hidden for KartaView: one left set must not hide cones. + passes: () => true, }); - /** Re-centre the cones when ‹ › walked off the page they show. */ - async function followStep(photoId) { + /** + * ‹ › open through the core (loading, errors, the sequence's cones), + * without moving the camera; the cones re-centre once ‹ › walk off the + * page they were drawn from. + */ + async function openStepped(photoId) { + const opened = await context.actions.openImage(photoId, { + frame: false, + }); const photo = photos.get(photoId); - if (!photo?.sequenceId) return; - anchors.set(photo.sequenceId, photo); + if (!opened || !photo?.sequenceId) return; if (drawnPage?.sequenceId !== photo.sequenceId) return; const window = await windowOf(photo).catch(() => null); - if (window && window.page !== drawnPage.page) + if (window && window.home !== drawnPage.page) cones.select(photo.sequenceId, { force: true }); } @@ -265,7 +331,7 @@ export function createKartaViewProvider({ return photo; }, step, - onStepped: followStep, + onStep: openStepped, render: context.services?.render, ...viewerOptions, }); @@ -316,12 +382,18 @@ export function createKartaViewProvider({ coverageStats() { const { hint } = state.coverage; const count = coverage.sequenceCount(); + const loading = state.coverage.pending.size > 0; + const empty = + state.coverage.zoom !== null && + !loading && + count === 0 && + !state.coverage.lastError; return { count, zoom: state.coverage.zoom, kind: state.coverage.kind, - loading: state.coverage.pending.size > 0, - hint: hint || '', + loading, + hint: hint || (empty ? NO_COVERAGE_HINT : ''), error: state.coverage.lastError, keyRequired: false, keyRejected: false, @@ -353,18 +425,25 @@ export function createKartaViewProvider({ loading: state.sequence.loading, }), - /** The nearest photo within the search radius, or null. */ + /** + * The nearest photo within the search radius that can be shown, or + * null. Many photos are archived (their files answer 409) or have no + * file at all, so the nearest few are tried in turn. + */ async nearestImage({ lat, lon }, { signal } = {}) { const records = await source.nearestPhotos({ lat, lon }, { signal }); - let best = null; + const near = []; for (const record of records) { const photo = remember(record); - if (!photo) continue; + if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); - if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres)) - best = { photo, metres }; + if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres }); } - return best ? best.photo.id : null; + near.sort((a, b) => a.metres - b.metres); + for (const { photo } of near.slice(0, NEAREST_PROBES)) + if (await showable(photo, { signal })) return photo.id; + if (near.length) throw new Error(ARCHIVED_MESSAGE); + return null; }, viewer, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 84a36d7d0..d2d75b5e2 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -47,6 +47,7 @@ export const COVERAGE_TUNING = Object.freeze({ viewNearM: 1_000, }); export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage'; +export const NO_COVERAGE_HINT = 'no KartaView photos in view'; export const RATE_LIMITED_MESSAGE = 'KartaView is rate-limiting requests — they resume on their own'; @@ -57,10 +58,23 @@ export const RATE_LIMITED_MESSAGE = export const PHOTO_API_CONCURRENCY = 2; export const PHOTO_API_RETRIES = 3; export const PHOTO_API_RETRY_BASE_MS = 800; +/** A Retry-After longer than this is not waited out. */ +export const PHOTO_API_RETRY_MAX_MS = 10_000; +/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */ +export const TILE_CONCURRENCY = 3; +export const PAGE_CONCURRENCY = 1; export const TILE_TIMEOUT_MS = 20_000; export const PHOTO_API_TIMEOUT_MS = 15_000; +/** A sequence page is about a megabyte of JSON. */ +export const PAGE_TIMEOUT_MS = 30_000; /** A photo download (tens of kilobytes to a few megabytes). */ -export const IMAGE_TIMEOUT_MS = 30_000; +export const IMAGE_TIMEOUT_MS = 15_000; +/** Whether a photo file is there (not archived): headers only. */ +export const IMAGE_PROBE_TIMEOUT_MS = 8_000; +/** Nearest photos tried before giving up on archived ones. */ +export const NEAREST_PROBES = 4; +export const ARCHIVED_MESSAGE = + 'KartaView’s photos here are archived and cannot be shown'; /** Photos asked for around a point; the API returns them nearest first. */ export const NEAREST_LIMIT = 10; @@ -69,9 +83,12 @@ export const NEAREST_SEARCH_ZOOM = 18; /** * Photos of a sequence fetched at once, around the open one. Records are - * ~2 kB each, so a page is ~2 MB; the cones and prev/next use it. + * ~2.4 kB each, so a page is ~1.2 MB; the cones and ‹ › use it. Pages are in + * photo-id order, which only roughly follows the sequence index. */ -export const SEQUENCE_PAGE_SIZE = 1000; +export const SEQUENCE_PAGE_SIZE = 500; +/** Within this many photos of a page's edge, its neighbour page joins it. */ +export const SEQUENCE_PAGE_EDGE = 12; /** Equirectangular panorama sizes: a quick preview, then the sharp one. */ export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); @@ -161,6 +178,9 @@ export function normalizePhoto(record) { if (!record || record.id == null) return null; const { lat, lon } = positionOf(record); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + // 0,0 is a missing fix, not a photo in the Gulf of Guinea. + if (lat === 0 && lon === 0) return null; + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; const heading = record.heading === '' || record.heading == null ? NaN @@ -195,9 +215,13 @@ export function normalizePhoto(record) { /** * Where the image link goes. KartaView's photo pages fail for signed-out - * visitors ("Sequence cannot be loaded"), so it is the full-size photo on - * KartaView's own storage. + * visitors ("Sequence cannot be loaded"), so it is the full-size photo + * through KartaView's image proxy, which also serves photos whose storage + * host answers 502 directly. */ export function kartaViewPhotoUrl(photo) { - return photo?.urls?.full || photo?.urls?.view || 'https://kartaview.org'; + const url = photo?.urls?.full || photo?.urls?.view; + return url + ? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}` + : 'https://kartaview.org'; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index b48e1742f..140a3903c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -60,7 +60,14 @@ test('photo files come from the storage host the record names, not the one its U photo.urls.full, 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', ); - assert.equal(kartaViewPhotoUrl(photo), photo.urls.full); + // The link goes through the image proxy, which serves photos whose + // storage host answers 502 directly. + const link = kartaViewPhotoUrl(photo); + assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/); + assert.equal( + Buffer.from(link.split('/').at(-1), 'base64url').toString(), + photo.urls.full, + ); // An unexpected storage value falls back to the URLs as given. const odd = normalizePhoto(record({ storage: 'https://evil.example' })); assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//); @@ -101,6 +108,14 @@ test('a 360° photo keeps its equirectangular image; the unmatched position is t test('records without an id or a position are dropped; a missing heading is null', () => { assert.equal(normalizePhoto(null), null); + // 0,0 is a missing fix. + assert.equal( + normalizePhoto( + record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }), + ), + null, + ); + assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null); assert.equal(normalizePhoto(record({ id: undefined })), null); assert.equal( normalizePhoto( diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index ef626fe09..582e32551 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -24,11 +24,19 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { }; } -/** A source over `sequences` (id → indexes present), recording calls. */ -function fakeSource({ nearest = [], sequences = {} } = {}) { - const calls = { nearest: 0, photo: [], pages: [] }; +/** + * A source over `sequences` (id → indexes present, in page order: KartaView + * pages follow photo ids, which only roughly follow the index), recording + * calls. Photos whose id is in `archived` answer 409. + */ +function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { + const calls = { nearest: 0, photo: [], pages: [], probes: [] }; return { calls, + probeImage: async (url) => { + calls.probes.push(url); + return !archived.some((id) => url.includes(`/${id}.jpg`)); + }, getTile: async () => ({ lines: [] }), nearestPhotos: async () => { calls.nearest++; @@ -59,7 +67,7 @@ function fakeSource({ nearest = [], sequences = {} } = {}) { } function fakeContext() { - const actions = { opened: [], nearest: [] }; + const actions = { opened: [], nearest: [], instance: null }; return { actions, context: { @@ -71,7 +79,12 @@ function fakeContext() { getSurface: () => 'draped', notify() {}, actions: { - openImage: (id) => actions.opened.push(id), + // As the core does: the provider's own viewer opens it. + openImage: async (id, options) => { + actions.opened.push({ id, options }); + await actions.instance?.viewer.open(id); + return true; + }, openNearest: (point) => actions.nearest.push(point), reportError() {}, }, @@ -96,6 +109,7 @@ function setup(sourceOptions) { }); const { context, actions } = fakeContext(); const instance = definition.create(context); + actions.instance = instance; return { definition, instance, source, actions, loads }; } @@ -159,8 +173,9 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing test('a cone opens its photo; a line opens the photo nearest the click', () => { const { instance, actions } = setup(); + actions.instance = null; assert.equal(instance.handlePick('kv:img:123'), true); - assert.deepEqual(actions.opened, ['123']); + assert.deepEqual(actions.opened, [{ id: '123', options: undefined }]); const point = { lon: -83.0458, lat: 42.3314 }; assert.equal( instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }), @@ -185,47 +200,124 @@ test('coverage stats report KartaView as configured and key-free', async () => { assert.equal(stats.count, 0); }); -test('› walks across sequence pages; ‹ walks back; the ends stop', async () => { +const press = async (button) => { + button.fire('click'); + for (let i = 0; i < 12; i++) await settle(); +}; + +async function mounted(sourceOptions) { + const env = setup(sourceOptions); + const host = fakeHost(); + await env.instance.viewer.mount(host); + const poses = []; + env.instance.viewer.onPose((pose) => poses.push(pose.imageId)); + const [, , prev, next] = host.children[0].children; + return { ...env, poses, prev, next }; +} + +test('› walks across sequence pages without moving the camera; ‹ walks back; the ends stop', async () => { const size = SEQUENCE_PAGE_SIZE; const indexes = Array.from({ length: size + 5 }, (_, i) => i); - const { instance, source } = setup({ sequences: { s: indexes } }); - const host = fakeHost(); - await instance.viewer.mount(host); - const poses = []; - instance.viewer.onPose((pose) => poses.push(pose.imageId)); - const [, , prev, next] = host.children[0].children; - const press = async (button) => { - button.fire('click'); - for (let i = 0; i < 6; i++) await settle(); - }; + const { instance, actions, source, poses, prev, next } = await mounted({ + sequences: { s: indexes }, + }); await instance.viewer.open(`s-${size - 1}`); await press(next); assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); - assert.deepEqual(source.calls.pages, ['s:1', 's:2']); + assert.deepEqual(actions.opened.at(-1), { + id: `s-${size}`, + options: { frame: false }, + }); + assert.ok(source.calls.pages.includes('s:2')); await press(prev); assert.equal(poses.at(-1), `s-${size - 1}`); await instance.viewer.open('s-0'); + for (let i = 0; i < 6; i++) await settle(); + assert.equal(prev.disabled, true, 'nothing before the first photo'); await press(prev); - assert.equal(poses.at(-1), 's-0', 'nothing before the first photo'); - assert.equal(prev.disabled, true); + assert.equal(poses.at(-1), 's-0'); }); -test('a page with missing photos is found by stepping back a page', async () => { +test('pages that overlap in index (id order) step one photo at a time across the edge', async () => { const size = SEQUENCE_PAGE_SIZE; - // Index `size + 100` sits on page 1 when 200 earlier photos are gone. - const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter( - (i) => i < 100 || i >= 300, + // As KartaView pages by id: page 1 holds 0…size+2 less two, page 2 starts + // with those two (size-2, size) before the rest. + const first = Array.from({ length: size + 3 }, (_, i) => i).filter( + (i) => i !== size - 2 && i !== size, + ); + const second = [ + size - 2, + size, + ...Array.from({ length: 50 }, (_, i) => size + 3 + i), + ]; + const { instance, poses, next } = await mounted({ + sequences: { s: [...first, ...second] }, + }); + await instance.viewer.open(`s-${size - 5}`); + for (let k = 1; k <= 8; k++) await press(next); + assert.deepEqual( + poses.slice(-9).map((id) => Number(id.slice(2))), + Array.from({ length: 9 }, (_, i) => size - 5 + i), + 'no photo skipped at the page edge', + ); +}); + +test('a photo whose index outruns the page count is found, and its neighbours are real', async () => { + // 1,000 photos with 500–1499 deleted: index 1500 is on page 2, not 4. + const indexes = [ + ...Array.from({ length: 500 }, (_, i) => i), + ...Array.from({ length: 500 }, (_, i) => 1500 + i), + ]; + const { instance, poses, prev, next } = await mounted({ + sequences: { s: indexes }, + }); + await instance.viewer.open('s-1500'); + await press(next); + assert.equal(poses.at(-1), 's-1501'); + await press(prev); + await press(prev); + assert.equal(poses.at(-1), 's-499', 'back across the gap'); + await instance.viewer.open('s-1999'); + for (let i = 0; i < 8; i++) await settle(); + assert.equal(next.disabled, true, 'the last photo'); +}); + +test('archived photos are skipped: by the nearest lookup, and by › and ‹', async () => { + const { instance, poses, next } = await mounted({ + nearest: [ + photoRecord('gone', { metres: 3 }), + photoRecord('kept', { metres: 9 }), + ], + archived: ['gone', 's-2', 's-3'], + sequences: { s: [0, 1, 2, 3, 4] }, + }); + assert.equal( + await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + 'kept', + ); + await instance.viewer.open('s-1'); + await press(next); + assert.equal(poses.at(-1), 's-4', 'two archived photos stepped over'); + const allGone = setup({ + nearest: [photoRecord('gone', { metres: 3 })], + archived: ['gone'], + }); + await assert.rejects( + allGone.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + /archived and cannot be shown/, + ); + const noFiles = setup({ + nearest: [ + { + ...photoRecord('bare', { metres: 2 }), + filepathLTh: '', + filepathProc: '', + }, + ], + }); + assert.equal( + await noFiles.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + null, + 'a record with no file is not a photo', ); - const { instance, source } = setup({ sequences: { s: indexes } }); - const host = fakeHost(); - await instance.viewer.mount(host); - const poses = []; - instance.viewer.onPose((pose) => poses.push(pose.imageId)); - const target = size + 100; - await instance.viewer.open(`s-${target}`); - const [, , , next] = host.children[0].children; - next.fire('click'); - for (let i = 0; i < 8; i++) await settle(); - assert.equal(poses.at(-1), `s-${target + 1}`); - assert.deepEqual(source.calls.pages.slice(0, 2), ['s:2', 's:1']); }); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 7d68c5f62..244ea38d8 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -1,11 +1,16 @@ import { + IMAGE_PROBE_TIMEOUT_MS, KARTAVIEW_API_HOST, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, + PAGE_CONCURRENCY, + PAGE_TIMEOUT_MS, PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES, PHOTO_API_RETRY_BASE_MS, + PHOTO_API_RETRY_MAX_MS, PHOTO_API_TIMEOUT_MS, + TILE_CONCURRENCY, TILE_TIMEOUT_MS, } from './policy.js'; @@ -19,7 +24,7 @@ export class KartaViewSourceError extends Error { } } -const NO_ANSWER = 'KartaView did not answer — check the network and try again'; +const NO_ANSWER = 'KartaView did not answer in time — try again'; /** Resolves after `ms`, or rejects when `signal` aborts first. */ function wait(ms, signal) { @@ -92,8 +97,11 @@ export function createLimiter(limit) { /** * KartaView's public API, browser-side and keyless (it allows any origin). - * Coverage tiles are not rate-limited; photo lookups are, so they queue two - * at a time and retry a 429. + * It speaks HTTP/1.1, so the browser opens six connections to it: tiles take + * at most three, photo lookups two and sequence pages one, so a lookup never + * waits behind slow tiles or pages. The photo API answers 429 to more than + * about three requests at once; a 429 (and a lookup that timed out) is + * retried. * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options] */ export function createKartaViewSource({ @@ -102,6 +110,8 @@ export function createKartaViewSource({ retryBaseMs = PHOTO_API_RETRY_BASE_MS, } = {}) { const limiter = createLimiter(PHOTO_API_CONCURRENCY); + const tileLimiter = createLimiter(TILE_CONCURRENCY); + const pageLimiter = createLimiter(PAGE_CONCURRENCY); /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ async function getJson(url, { signal, timeoutMs }) { @@ -137,32 +147,52 @@ export function createKartaViewSource({ } } - /** A photo-API call through the limiter, retrying 429s with backoff. */ - async function photoApi(path, params, { signal } = {}) { + /** + * A photo-API call through `gate`, retrying 429s with backoff and, once, a + * call that timed out. + */ + async function photoApi( + path, + params, + { signal } = {}, + { gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {}, + ) { const query = params ? `?${new URLSearchParams(params)}` : ''; const url = `${apiHost}${path}${query}`; + let timeouts = 0; for (let attempt = 0; ; attempt++) { try { - const payload = await limiter.run( - () => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }), + const payload = await gate.run( + () => getJson(url, { signal, timeoutMs }), { signal }, ); const code = Number(payload?.status?.apiCode); // 600 ok, 601 empty, 602 partial: anything else is a refusal. if (Number.isFinite(code) && (code < 600 || code > 603)) throw new KartaViewSourceError( - String( - payload?.status?.apiMessage || 'KartaView refused the request', - ).trim(), + `KartaView refused the request (code ${code})`, { status: Number(payload?.status?.httpCode) || 0 }, ); return payload?.result ?? {}; } catch (error) { - if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error; + if (signal?.aborted) throw error; + const timedOut = error?.message === NO_ANSWER && error.status === 0; + if (timedOut && timeouts++ < 1) continue; + if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) { + if (error?.status === 429) + throw new KartaViewSourceError( + 'KartaView is rate-limiting requests — try again in a moment', + { status: 429, retryAfterSec: error.retryAfterSec }, + ); + throw error; + } await wait( - Math.max( - retryBaseMs * 2 ** attempt, - (error.retryAfterSec ?? 0) * 1000, + Math.min( + PHOTO_API_RETRY_MAX_MS, + Math.max( + retryBaseMs * 2 ** attempt, + (error.retryAfterSec ?? 0) * 1000, + ), ), signal, ); @@ -173,10 +203,42 @@ export function createKartaViewSource({ return { /** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */ async getTile({ x, y, z }, { signal } = {}) { - return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, { - signal, - timeoutMs: TILE_TIMEOUT_MS, - }); + return tileLimiter.run( + () => + getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, { + signal, + timeoutMs: TILE_TIMEOUT_MS, + }), + { signal }, + ); + }, + + /** + * Whether a photo file can be fetched. Archived photos answer 409 (and + * a HEAD says 200 regardless), so this GETs and stops at the headers. + */ + async probeImage(url, { signal } = {}) { + if (!url) return false; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + if (signal?.aborted) throw signal.reason; + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => controller.abort(), + IMAGE_PROBE_TIMEOUT_MS, + ); + try { + const response = await fetchImpl(url, { signal: controller.signal }); + return response.ok; + } catch { + if (signal?.aborted) throw signal.reason; + return false; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + // The body is not needed: drop it. + controller.abort(); + } }, /** Photo records around a point, nearest first. */ @@ -216,6 +278,7 @@ export function createKartaViewSource({ `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, { page: String(page), itemsPerPage: String(itemsPerPage) }, { signal }, + { gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS }, ); return { photos: Array.isArray(result.data) ? result.data : [], diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 8d8bca033..73fa3b1bb 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -5,7 +5,11 @@ import { createKartaViewSource, createLimiter, } from './source.js'; -import { PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES } from './policy.js'; +import { + PHOTO_API_CONCURRENCY, + PHOTO_API_RETRIES, + TILE_CONCURRENCY, +} from './policy.js'; const ok = (body) => ({ ok: true, @@ -87,12 +91,13 @@ test('rate-limiting that outlasts the retries is reported with a wait', async () assert.ok(error instanceof KartaViewSourceError); assert.equal(error.status, 429); assert.ok(error.retryAfterSec > 0); + assert.match(error.message, /try again in a moment/); return true; }); assert.equal(calls, PHOTO_API_RETRIES + 1); }); -test('an API refusal inside a 200 is an error with KartaView’s message; 601 is just empty', async () => { +test('an API refusal inside a 200 is a readable error with its code; 601 is just empty', async () => { const refusing = createKartaViewSource({ fetchImpl: async () => ok({ @@ -100,7 +105,10 @@ test('an API refusal inside a 200 is an error with KartaView’s message; 601 is result: {}, }), }); - await assert.rejects(refusing.getPhoto('1'), /out of range/); + await assert.rejects( + refusing.getPhoto('1'), + /KartaView refused the request \(code 612\)/, + ); const empty = createKartaViewSource({ fetchImpl: async () => ok({ status: { apiCode: 601 }, result: { data: [] } }), @@ -190,3 +198,92 @@ test('the limiter runs tasks in order and keeps going after a failure', async () await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(limiter.pending, 0); }); + +test('tiles, lookups and sequence pages queue separately, so a lookup never waits behind them', async () => { + const running = { tile: 0, photo: 0, page: 0 }; + const peak = { tile: 0, photo: 0, page: 0 }; + const release = []; + const kind = (url) => + url.includes('/tiles/') + ? 'tile' + : url.includes('/photos') + ? 'page' + : 'photo'; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + const k = kind(url); + running[k]++; + peak[k] = Math.max(peak[k], running[k]); + if (k !== 'photo') await new Promise((resolve) => release.push(resolve)); + running[k]--; + return k === 'tile' ? ok({ lines: [] }) : ok(photoPayload([])); + }, + }); + const tiles = Array.from({ length: 9 }, (_, i) => + source.getTile({ x: i, y: 0, z: 14 }), + ); + const pages = [1, 2].map((page) => + source.getSequencePhotos('1', { page, itemsPerPage: 500 }), + ); + // Every slow tile and page is in flight or queued; a lookup still answers. + assert.deepEqual(await source.nearestPhotos({ lat: 1, lon: 1 }), []); + assert.equal(peak.tile, TILE_CONCURRENCY); + assert.equal(peak.page, 1); + while (release.length) { + release.shift()(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + await Promise.all([...tiles, ...pages]); + assert.equal(peak.tile, TILE_CONCURRENCY); + assert.equal(peak.page, 1); +}); + +test('a lookup that times out is asked once more; a second timeout says to try again', async () => { + let calls = 0; + const source = createKartaViewSource({ + fetchImpl: async () => { + calls++; + throw new TypeError('Failed to fetch'); + }, + }); + await assert.rejects( + source.getPhoto('1'), + /did not answer in time — try again/, + ); + assert.equal(calls, 2); +}); + +test('an image probe reads only the status: an archived photo (409) is not showable', async () => { + const seen = []; + const source = createKartaViewSource({ + fetchImpl: async (url, { signal }) => { + seen.push(signal); + return { + ok: !url.includes('archived'), + status: url.includes('archived') ? 409 : 200, + }; + }, + }); + assert.equal( + await source.probeImage('https://storage13.openstreetcam.org/ok.jpg'), + true, + ); + assert.equal( + await source.probeImage('https://storage13.openstreetcam.org/archived.jpg'), + false, + ); + assert.equal(await source.probeImage(null), false); + assert.ok( + seen.every((signal) => signal.aborted), + 'the body is dropped', + ); + const offline = createKartaViewSource({ + fetchImpl: async () => { + throw new TypeError('Failed to fetch'); + }, + }); + assert.equal( + await offline.probeImage('https://x.openstreetcam.org/a.jpg'), + false, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index 27c5bee6b..d3fc218e7 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -49,7 +49,7 @@ export function loadImage( * @param {object} options * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. - * @param {(imageId: string) => void} [options.onStepped] A photo opened from ‹ ›, after its pose. + * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). * @param {object} [options.render] Render governor. * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] @@ -58,7 +58,7 @@ export function loadImage( export function createKartaViewViewer({ getPhoto, step, - onStepped = () => {}, + onStep, render, load = loadImage, createRenderer = createPanoramaRenderer, @@ -80,6 +80,12 @@ export function createKartaViewViewer({ let drag = null; let poseFrame = 0; let removeListeners = []; + /** The photo an open is fetching: ‹ › step on from it, not the old one. */ + let openingId = null; + /** ‹ › chose this photo: keep the panorama's look direction for it. */ + let keepViewFor = null; + /** ‹ › presses run one after another, each from where the last one went. */ + let steps = Promise.resolve(); function requestRender() { render?.governorRequestRender?.('kartaview-viewer'); @@ -133,6 +139,21 @@ export function createKartaViewViewer({ const enabled = Boolean(photo?.sequenceId) && showing !== null; if (prevButton) prevButton.disabled = !enabled; if (nextButton) nextButton.disabled = !enabled; + if (enabled) checkEnds(photo); + } + + /** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */ + function checkEnds(target) { + for (const [delta, button] of [ + [-1, prevButton], + [1, nextButton], + ]) + step(target, delta).then( + (id) => { + if (photo === target && button && !id) button.disabled = true; + }, + () => {}, + ); } function show(kind) { @@ -159,29 +180,33 @@ export function createKartaViewViewer({ return renderer; } - /** Step along the sequence from the open photo. */ - async function stepBy(delta) { - const from = photo; - if (!from?.sequenceId || !showing) return; - const seq = openSeq; - let nextId = null; - try { - nextId = await step(from, delta); - } catch { - nextId = null; - } - if (seq !== openSeq || photo !== from) return; - if (!nextId) { - const button = delta < 0 ? prevButton : nextButton; - if (button) button.disabled = true; - return; - } - try { - await adapter.open(nextId, { keepView: true }); - } catch { - return; - } - if (photo?.id === nextId) onStepped(nextId); + /** + * Step along the sequence from the photo showing, or from the one on its + * way after an earlier press, and open it through the core. + */ + function stepBy(delta) { + steps = steps.then(async () => { + const fromId = openingId ?? photo?.id; + if (!fromId || !root) return; + let from; + let nextId = null; + try { + from = await getPhoto(fromId); + if (from.sequenceId) nextId = await step(from, delta); + } catch { + nextId = null; + } + // Closed, or another photo was opened meanwhile. + if ((openingId ?? photo?.id) !== fromId) return; + if (!nextId) { + const button = delta < 0 ? prevButton : nextButton; + if (button && photo?.id === fromId) button.disabled = true; + return; + } + openingId = nextId; + keepViewFor = nextId; + onStep(nextId); + }); } function onPointerDown(event) { @@ -324,15 +349,12 @@ export function createKartaViewViewer({ } async function showPano(target, seq, keepView) { - const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE); - let image; - try { - image = await load(preview, { crossOrigin: true }); - } catch { - image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), { - crossOrigin: true, - }); - } + // Only the proxy can shrink a 13,000 px panorama: if it fails, the + // caller shows the front view rather than waiting on it twice. + const image = await load( + resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), + { crossOrigin: true }, + ); if (seq !== openSeq) return false; if (!keepView || showing !== 'pano') renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); @@ -359,6 +381,24 @@ export function createKartaViewViewer({ return true; } + async function openPhoto(id, seq, keepView) { + const target = await getPhoto(id); + if (seq !== openSeq) return; + let shown = false; + if (target.isPano && target.urls.pano && ensureRenderer()) { + try { + shown = await showPano(target, seq, keepView); + } catch { + // The panorama failed: its front view is better than nothing. + if (seq !== openSeq) return; + shown = await showFlat(target, seq); + } + } else shown = await showFlat(target, seq); + if (!shown) return; + emit(); + upgrade(target, seq, showing); + } + const adapter = { async mount(host) { if (!host) throw new Error('Street Level viewer has no host element'); @@ -368,28 +408,24 @@ export function createKartaViewViewer({ }, /** Resolves once the photo is on screen and its pose was emitted. */ - async open(imageId, { keepView = false } = {}) { + async open(imageId) { if (!root) throw new Error('KartaView viewer is not mounted'); + const id = String(imageId); const seq = ++openSeq; - const target = await getPhoto(String(imageId)); - if (seq !== openSeq) return; - let shown = false; - if (target.isPano && target.urls.pano && ensureRenderer()) { - try { - shown = await showPano(target, seq, keepView); - } catch { - // The panorama failed: its front view is better than nothing. - if (seq !== openSeq) return; - shown = await showFlat(target, seq); - } - } else shown = await showFlat(target, seq); - if (!shown) return; - emit(); - upgrade(target, seq, showing); + const keepView = keepViewFor === id; + keepViewFor = null; + openingId = id; + try { + await openPhoto(id, seq, keepView); + } finally { + if (openingId === id && seq === openSeq) openingId = null; + } }, close() { openSeq++; + openingId = null; + keepViewFor = null; photo = null; show(null); if (picture) picture.removeAttribute('src'); @@ -398,6 +434,8 @@ export function createKartaViewViewer({ unmount() { openSeq++; + openingId = null; + keepViewFor = null; teardown(); }, diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index f15c6f3fc..7372dea6c 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -80,7 +80,11 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { return PHOTOS[id]; }, step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null, - onStepped: (id) => stepped.push(id), + // The core opens what ‹ › chose; here the adapter is opened directly. + onStep: (id) => { + stepped.push(id); + viewer.open(id).catch(() => {}); + }, load: async (url, options) => { loads.push({ url, crossOrigin: options?.crossOrigin }); if (failLoads.some((part) => url.includes(part))) @@ -108,7 +112,9 @@ const parts = (host) => { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => { - const { viewer, poses, host, loads } = setup(); + const { viewer, poses, host, loads } = setup({ + steps: { 'flat1:1': 'flat2', 'flat1:-1': 'flat0' }, + }); await viewer.mount(host); const { picture, canvas, prev, next } = parts(host); assert.equal(prev.disabled, true, 'nothing to step through yet'); @@ -142,7 +148,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa title: null, }, ); - assert.equal(pose.externalUrl, PHOTOS.flat1.urls.full); + assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//); assert.equal(loads[0].url, PHOTOS.flat1.urls.view); assert.equal(loads[0].crossOrigin, false, 'an needs no CORS'); await settle(); @@ -222,26 +228,43 @@ test('without WebGL, or when the panorama fails, a 360° photo shows its front v assert.equal(broken.poses.at(-1).imageId, 'pano1'); }); -test('‹ › and the arrow keys step along the sequence; the end disables the button', async () => { +test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => { const { viewer, poses, host, stepped } = setup({ - steps: { 'flat1:1': 'flat2' }, + steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' }, }); await viewer.mount(host); await viewer.open('flat1'); const { root, prev, next } = parts(host); + await settle(); + assert.equal(prev.disabled, true, 'flat1 is the first photo: known up front'); + assert.equal(next.disabled, false); next.fire('click'); - await settle(); - await settle(); + for (let i = 0; i < 4; i++) await settle(); assert.equal(poses.at(-1).imageId, 'flat2'); - assert.deepEqual(stepped, ['flat2']); - root.fire('keydown', { key: 'ArrowRight' }); - await settle(); - await settle(); + assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it'); assert.equal(next.disabled, true, 'flat2 is the last photo'); + assert.equal(prev.disabled, false); root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); await settle(); assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours'); - assert.equal(prev.disabled, false); + root.fire('keydown', { key: 'ArrowLeft' }); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(poses.at(-1).imageId, 'flat1'); +}); + +test('quick presses each step on from the photo the last one chose', async () => { + const chain = { 'flat1:1': 'flat2', 'flat2:1': 'pano1' }; + const { viewer, host, stepped } = setup({ + steps: chain, + rendererThrows: true, + }); + await viewer.mount(host); + await viewer.open('flat1'); + const { next } = parts(host); + next.fire('click'); + next.fire('click'); + for (let i = 0; i < 8; i++) await settle(); + assert.deepEqual(stepped, ['flat2', 'pano1']); }); test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => { diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 1e83ee7d4..2607ab542 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -22,7 +22,8 @@ export function filtersOf(provider) { * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). - * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). + * @property {{noun: string, action: string}} [coverageLabel] + * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -33,7 +34,7 @@ export function filtersOf(provider) { * @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service. * @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`. * @property {() => void} notify Ask the core to publish a new UI snapshot. - * @property {{openImage: (imageId: string) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions + * @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise, openNearest: (point: {lon: number, lat: number}) => Promise, reportError: (message: string|null) => void}} actions * * @typedef {object} ProviderInstance * @property {() => Promise<{configured: boolean}>} status diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index a2aeca1ce..8eb79b29f 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -49,6 +49,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { * @param {string} options.renderKey Render-governor reason. * @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight] * Highlight (or stop highlighting) the sequence's coverage line. + * @param {(image: ConeImage, filter: object) => boolean} [options.passes] + * Whether the imagery filter keeps a cone. */ export function createSequenceCones({ state, @@ -58,6 +60,7 @@ export function createSequenceCones({ spriteId, renderKey, onHighlight = () => {}, + passes = passesImageryFilter, }) { const { render, sprites } = state.services; @@ -154,7 +157,7 @@ export function createSequenceCones({ // Resolved now, so a "since N days" window keeps up with the clock. const filter = state.context.getFilter(); images.forEach((image, index) => { - if (!passesImageryFilter(image, filter)) return; + if (!passes(image, filter)) return; const height = heights?.[index] ?? null; const billboard = collection.add({ id: `${pickPrefix}${image.id}`, diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 77c6bf173..cab0d12b1 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -18,6 +18,7 @@ import { COLORS } from './policy.js'; * @property {boolean} [groundClick] * @property {Array<'pano'|'since'>} [filters] * @property {{noun: string, action: string}|null} [coverageLabel] + * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. */ /** @@ -95,7 +96,13 @@ export function composeUIState({ label: provider.name, color: provider.color, })); - if (legend.length) + // "Selected" only beside a source whose lines turn cyan when selected. + if ( + active.some( + (provider) => + provider.groundClick !== true && provider.highlightsSequence !== false, + ) + ) legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); return { enabled, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index fe0b19d82..813213c4a 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -236,3 +236,25 @@ test('a key-gated layer offers the switched-off sources that would draw with a k [], ); }); + +test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + color: '#635bff', + highlightsSequence: false, + coverageLabel: { + noun: 'streets', + action: 'click a line for its nearest photo', + }, + }); + const ui = composeUIState({ ...base, providers: [kartaView] }); + assert.deepEqual( + ui.legend.map((entry) => entry.key), + ['kartaview'], + ); + assert.deepEqual(ui.coverage.label, { + noun: 'streets', + action: 'click a line for its nearest photo', + }); +}); From 1a2fbda46477975c7be27a2416ec92d148fa00da Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 19:49:51 +0100 Subject: [PATCH 21/78] fix(street-level): probe KartaView's nearest photos side by side Six candidates at once on photo storage (not the API host): the nearest showable one wins. Jakarta line clicks opened 5/8 (0/5 before the QA round; the rest are archived and say so), Detroit 8/8. Co-Authored-By: Claude Opus 5.5 --- src/layers/streetLevel/providers/kartaview/index.js | 10 ++++++++-- src/layers/streetLevel/providers/kartaview/policy.js | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index a056771dc..b7eb0f567 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -440,8 +440,14 @@ export function createKartaViewProvider({ if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); - for (const { photo } of near.slice(0, NEAREST_PROBES)) - if (await showable(photo, { signal })) return photo.id; + // Probed side by side (photo storage is not the API host): the + // nearest one that answers wins. + const candidates = near.slice(0, NEAREST_PROBES); + const answers = await Promise.all( + candidates.map(({ photo }) => showable(photo, { signal })), + ); + const hit = candidates.find((_, i) => answers[i]); + if (hit) return hit.photo.id; if (near.length) throw new Error(ARCHIVED_MESSAGE); return null; }, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index d2d75b5e2..699f7d519 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -71,8 +71,8 @@ export const PAGE_TIMEOUT_MS = 30_000; export const IMAGE_TIMEOUT_MS = 15_000; /** Whether a photo file is there (not archived): headers only. */ export const IMAGE_PROBE_TIMEOUT_MS = 8_000; -/** Nearest photos tried before giving up on archived ones. */ -export const NEAREST_PROBES = 4; +/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */ +export const NEAREST_PROBES = 6; export const ARCHIVED_MESSAGE = 'KartaView’s photos here are archived and cannot be shown'; From bd0bbf5134ade6912a776e5956f3e806a0b06345 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 20:08:23 +0100 Subject: [PATCH 22/78] =?UTF-8?q?fix(street-level):=20IC8=20review=20of=20?= =?UTF-8?q?KartaView=20=E2=80=94=20cones=20on=20the=20right=20page,=20pano?= =?UTF-8?q?=20sizing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1s from the correctness review, each with a regression test: - Cones showed the wrong page of a long sequence (cache keyed by sequence; no re-centre after a close or a same-drive line click): KartaView always reselects, and re-centres whenever a photo of the selected sequence opens on another page. - A 360° photo's first frame was one colour: the canvas was sized while hidden (1×1). It is shown first, and re-measured every frame. P2/P3s: a storage timeout or 5xx is not remembered as archived (only 403/404/409/410 are); dense z13 tiles draw up to 1,600 lines (a third of downtown Detroit was cut); decoded tiles are kept 10 min; a timed-out tile is asked once more; pages are 250 photos and the newest page request goes first; a photo far past deleted ones, or unlisted at a page edge, still finds its neighbours; a step that gets no answer is not the end; a lost WebGL context redraws the photo, and a failed one is retried on the next mount; the first flat image comes from the proxy (KartaView's own can be 1.7 MB); fileurl fallbacks must be https storage hosts; oversized answers are refused; keyboard (Shift+← →, ↑ ↓, + −) and pinch look around a panorama; focus stays in the viewer when a step button disables. Docs: share links with 0.m.0, keyless count, CURRENT-STATE matched to the code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 8 +- docs/CURRENT-STATE.md | 65 +++++--- .../streetLevel/providers/kartaview/index.js | 124 +++++++++++--- .../providers/kartaview/panorama.js | 9 +- .../streetLevel/providers/kartaview/policy.js | 52 ++++-- .../providers/kartaview/policy.test.mjs | 12 ++ .../providers/kartaview/provider.test.mjs | 144 ++++++++++++++++- .../streetLevel/providers/kartaview/source.js | 54 +++++-- .../providers/kartaview/source.test.mjs | 61 ++++++- .../streetLevel/providers/kartaview/viewer.js | 129 ++++++++++++--- .../providers/kartaview/viewer.test.mjs | 151 ++++++++++++++++-- src/layers/streetLevel/registry.js | 5 +- 13 files changed, 699 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20a5efb4..d0df3bc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,7 @@ look, wheel to zoom), with cones along its drive and ‹ › / ← → to step through it. The caption links to the full-size photo (KartaView's photo pages fail for signed-out visitors), and the globe credits "© KartaView - contributors, CC BY-SA 4.0". Share links carry it as `r.kartaview-1`. + contributors, CC BY-SA 4.0". Share links carry it as `0.m.0_0.r.kartaview-1`. Archived photos (KartaView answers 409) are skipped by the click and by ‹ ›; a keyboard and pinch look around a 360° photo. Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index 0655a7c54..b58a2eaec 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -302,7 +302,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, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟡 Mapillary token · 🔴 Google Maps key · KartaView keyless | +| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View and KartaView; Panoramax planned | Mapillary · Google · KartaView | 🟢 KartaView · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **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 | 🟢 | @@ -344,9 +344,9 @@ _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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**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), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -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.r.google-1` Street View on, `0.r.kartaview-1` KartaView on, `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`. +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.m.0_0.r.google-1` Street View on, `0.m.0_0.r.kartaview-1` KartaView on (a link that leaves out `m` keeps Mapillary, the default, lit), `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 e984d0ee3..449f1cf68 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3192,28 +3192,49 @@ on `api.kartaview.org` (the documented `api.openstreetcam.org` answers but serves empty tiles), `cdn.kartaview.org` and `storage*.openstreetcam.org`. Its chip starts off. Coverage comes from z14 JSON tiles below 2.5 km above ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not -answer), so higher up the hint says to zoom in. A tile's lines are OSM way -segments with photos and carry no sequence, date or projection, so -`decode.js` chains segments that meet end to start (about a quarter of the -lines), they are drawn unselectable as `kv:line:`, and the provider -declares `filters: []` and `coverageLabel` ("N streets in view · click a line -for its nearest photo"). A click on a line reaches `handlePick(id, -{groundPoint})`, and the provider asks the core (`actions.openNearest`) for -its photo nearest that point within 50 m, without framing. The open photo's -sequence draws as cones from the 1,000-photo page holding it -(`/2.0/sequence/{id}/photos`; a page with missing photos is found by stepping -back), and ‹ › step across pages, re-centring the cones. The photo API answers -429 to more than about three requests at once, so `source.js` keeps two in -flight and retries a 429 with backoff. Photo URLs are built from `storage` and -`filepath*`: the API's `fileurl*` always name storage1, where photos stored -elsewhere 404. The viewer (`viewer.js`) shows a flat photo's 1280 px rendition, -then a 2,560 px one from the image proxy (`pr:sharp/rs:fit:W:H/`), -and a 360° photo's equirectangular `wrapped_proc` image (13,000 px wide -upstream) resized to 1,024 then 4,096 px on `panorama.js`, a single-triangle -WebGL renderer (image centre = the camera heading; drag, wheel). Without WebGL -a 360° photo shows its front view. FIT / FILL hide for panoramas -(`supportsRenderModes`). The caption has no place name (KartaView's is where -the drive began) and links to the full-size photo, since kartaview.org's photo +answer), so higher up the hint says to zoom in, and an empty view says there +are no KartaView photos in it. A tile's lines are OSM way segments with +photos and carry no sequence, date or projection, so `decode.js` chains +segments that meet end to start (about four times fewer lines), they are +drawn as `kv:line:`, up to 1,600 per tile, and never highlighted +(`highlightsSequence: false`, so the legend has no "Selected"), and the +provider declares `filters: []` and `coverageLabel` ("N streets in view · +click a line for its nearest photo"). Tiles come uncompressed and +uncacheable, so the last 24 decoded tiles are kept for 10 minutes +(`createTileCache`); one that does not answer in 20 s is asked once more, +and one that is not a line list is not cached. A click on a line reaches +`handlePick(id, {groundPoint})`, and the provider asks the core +(`actions.openNearest`) for its nearest showable photo within 50 m, without +framing: many photos are archived (their file answers 409, while a HEAD says +200) or have no file, so the six nearest are probed at once with a GET cut +off at the headers, and where all are gone the panel says KartaView's photos +there are archived. Only a definite answer (403/404/409/410, or 200) is +remembered; a storage timeout or 5xx is asked again. The open photo's +sequence draws as cones from the 250-photo page holding it +(`/2.0/sequence/{id}/photos`). Pages follow photo ids, which only roughly +follow the index, so the page is found by search (both ways, halving past the +end when many photos were deleted), a neighbour page joins within 12 photos +of an edge, and the cones re-centre whenever a photo of the selected sequence +opens on another page. ‹ › (and ← →) open through the core without framing, +step on from the photo already on its way, skip archived photos, and disable +up front at the ends. `api.kartaview.org` speaks HTTP/1.1, so of the +browser's six connections to it tiles take three, lookups two and pages one +(newest first), and a lookup never waits behind tiles; the photo API answers +429 to more than about three requests at once, so a 429 is retried with +backoff (at most 10 s) and a timed-out lookup once. Photo URLs are built from +`storage` and `filepath*`: the API's `fileurl*` always name storage1, where +photos stored elsewhere 404, and a `fileurl*` fallback must itself be an +https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px, +then 2,560 px, from the image proxy (`pr:sharp/rs:fit:W:H/`), +with KartaView's own 1,280 px file as the fallback, and a 360° photo's +equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to +1,024 then 4,096 px on `panorama.js`, a single-triangle WebGL renderer (image +centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the +canvas re-measures every frame). Without WebGL, or if the proxy fails within +15 s, a 360° photo shows its front view; a lost context draws the photo again +on a new one. FIT / FILL hide for panoramas (`supportsRenderModes`). The +caption has no place name (KartaView's is where the drive began) and links to +the full-size photo through the image proxy, since kartaview.org's photo pages fail for signed-out visitors. Street Level has two browser gates. `npm run qa:street-level -- --url ` diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index b7eb0f567..07e9672cf 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -30,7 +30,10 @@ import { /** Photos kept in memory by id (sequence pages fill it). */ const PHOTO_CACHE_SIZE = 4000; /** Sequence pages kept in memory. */ -const PAGE_CACHE_SIZE = 8; +const PAGE_CACHE_SIZE = 12; +/** Decoded coverage tiles kept, and for how long. */ +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; /** z14 near the street, z13 a little higher, nothing above. */ export function kartaViewZoomFor(heightM) { @@ -62,6 +65,30 @@ function createLru(limit) { }; } +/** + * Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView + * sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so + * a pan back or a zoom across z13/z14 would fetch them all again. + */ +export function createTileCache({ + load, + size = TILE_CACHE_SIZE, + ttlMs = TILE_CACHE_TTL_MS, + now = Date.now, +}) { + const kept = createLru(size); + return { + async get(tile, options) { + const key = `${tile.z}/${tile.x}/${tile.y}`; + const entry = kept.get(key); + if (entry && now() - entry.at < ttlMs) return entry.tile; + const loaded = await load(tile, options); + kept.set(key, { at: now(), tile: loaded }); + return loaded; + }, + }; +} + /** The 1-based page of a sequence that holds `index`, if no photo is missing. */ function pageOf(index) { return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1; @@ -184,9 +211,21 @@ export function createKartaViewProvider({ async function homePage(photo) { let number = pageOf(photo.sequenceIndex ?? 0); const index = photo.sequenceIndex ?? 0; + let page = await loadPage(photo.sequenceId, number); + if (!page.photos.length && number > 1) { + // Past the end (many photos deleted): find the last page by halving. + let low = 1; + let high = number - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((await loadPage(photo.sequenceId, middle)).photos.length) + low = middle; + else high = middle - 1; + } + number = low; + } const seen = new Set(); - let page = null; - while (!seen.has(number) && seen.size < 6) { + while (!seen.has(number) && seen.size < 8) { seen.add(number); page = await loadPage(photo.sequenceId, number); if (page.photos.some((p) => p.id === photo.id)) return page; @@ -208,11 +247,18 @@ export function createKartaViewProvider({ async function windowOf(photo) { const home = await homePage(photo); const list = home.photos; - const at = list.findIndex((p) => p.id === photo.id); + const found = list.findIndex((p) => p.id === photo.id); + // A photo no page lists sits where its index would. + const at = + found !== -1 + ? found + : list.filter( + (p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0), + ).length; const parts = [list]; - if (at !== -1 && at < SEQUENCE_PAGE_EDGE && home.page > 1) + if (at < SEQUENCE_PAGE_EDGE && home.page > 1) parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos); - if (at !== -1 && at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore) + if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore) parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos); if (parts.length === 1) return { home: home.page, photos: list }; const byId = new Map(parts.flat().map((p) => [p.id, p])); @@ -224,19 +270,39 @@ export function createKartaViewProvider({ }; } - /** Photo id → whether its file answers (archived ones do not), per session. */ + /** + * Photo id → whether its file is there (archived ones are not). Only + * a definite answer is kept: a storage hiccup is asked again next + * time, and meanwhile the photo is tried. A probe is shared, so it + * runs to its own timeout rather than any one caller's abort. + */ const probes = createLru(PHOTO_CACHE_SIZE); - function showable(photo, { signal } = {}) { + function showable(photo) { if (!photo?.urls?.view) return Promise.resolve(false); let probe = probes.get(photo.id); if (!probe) { - probe = source.probeImage(photo.urls.view, { signal }); + probe = source.probeImage(photo.urls.view).then( + (answer) => { + if (answer === null) probes.delete(photo.id); + return answer !== false; + }, + () => { + probes.delete(photo.id); + return true; + }, + ); probes.set(photo.id, probe); - probe.catch(() => probes.delete(photo.id)); } return probe; } + // Decoded coverage tiles, kept a while: panning back asks nothing. + const tiles = createTileCache({ + load: async (tile, { signal }) => + decodeCoverageTile(await source.getTile(tile, { signal }), tile), + }); + const fetchTile = (tile, options) => tiles.get(tile, options); + /** * The next showable photo `delta` along the sequence, or null at its * end. Archived photos are skipped, a few at most. @@ -269,8 +335,7 @@ export function createKartaViewProvider({ const coverage = createLineCoverage({ state, - fetchTile: async (tile, { signal }) => - decodeCoverageTile(await source.getTile(tile, { signal }), tile), + fetchTile, zoomFor: kartaViewZoomFor, colors: COLORS, pickPrefix: PICK_PREFIX.line, @@ -307,20 +372,28 @@ export function createKartaViewProvider({ passes: () => true, }); + /** ‹ › open through the core (loading, errors, cones), camera still. */ + function openStepped(photoId) { + context.actions.openImage(photoId, { frame: false }); + } + /** - * ‹ › open through the core (loading, errors, the sequence's cones), - * without moving the camera; the cones re-centre once ‹ › walk off the - * page they were drawn from. + * The cones show one page of a sequence: once a photo of the selected + * sequence opens on another page (‹ ›, a cone, a line), re-centre them. */ - async function openStepped(photoId) { - const opened = await context.actions.openImage(photoId, { - frame: false, - }); - const photo = photos.get(photoId); - if (!opened || !photo?.sequenceId) return; - if (drawnPage?.sequenceId !== photo.sequenceId) return; + async function recentreCones(photo) { + if (!photo?.sequenceId) return; + if (state.sequence.selectedId !== photo.sequenceId) return; + const drawn = drawnPage; + if (drawn?.sequenceId !== photo.sequenceId) return; const window = await windowOf(photo).catch(() => null); - if (window && window.home !== drawnPage.page) + // Still the drawn page and the latest photo once the page is in. + if ( + window && + drawnPage === drawn && + window.home !== drawn.page && + anchors.get(photo.sequenceId) === photo + ) cones.select(photo.sequenceId, { force: true }); } @@ -332,6 +405,7 @@ export function createKartaViewProvider({ }, step, onStep: openStepped, + onShown: recentreCones, render: context.services?.render, ...viewerOptions, }); @@ -414,7 +488,9 @@ export function createKartaViewProvider({ return false; }, - selectSequence: (sequenceId) => cones.select(sequenceId), + // Never from the cache: it is keyed by sequence, the cones by page. + selectSequence: (sequenceId) => + cones.select(sequenceId, { force: true }), clearSequence: () => { cones.clearSelection(); drawnPage = null; diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/providers/kartaview/panorama.js index a081d1e5e..de9a1b603 100644 --- a/src/layers/streetLevel/providers/kartaview/panorama.js +++ b/src/layers/streetLevel/providers/kartaview/panorama.js @@ -136,7 +136,8 @@ export function createPanoramaRenderer(canvas) { let frame = 0; const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }; - function resize() { + /** Match the drawing buffer to the canvas's laid-out size. */ + function syncSize() { const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); const width = Math.max(1, Math.round(canvas.clientWidth * ratio)); const height = Math.max(1, Math.round(canvas.clientHeight * ratio)); @@ -144,12 +145,18 @@ export function createPanoramaRenderer(canvas) { canvas.width = width; canvas.height = height; } + } + + function resize() { + syncSize(); scheduleRender(); } function draw() { frame = 0; if (gl.isContextLost()) return; + // Every frame: the canvas may have been laid out since the last one. + syncSize(); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 699f7d519..9df86b887 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -37,8 +37,9 @@ export const COVERAGE_MAX_HEIGHT_M = 6_000; export const COVERAGE_TUNING = Object.freeze({ maxTiles: 9, overviewMaxTiles: 0, - // Chained ways are short: a dense z13 tile holds ~1,300. - maxSequences: 9_000, + // Chained ways are short: a dense downtown z13 tile holds ~1,500, so each + // tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn). + maxSequences: 14_400, lineWidthPx: 2.5, overviewPointPx: 2.5, moveDebounceMs: 320, @@ -65,8 +66,10 @@ export const TILE_CONCURRENCY = 3; export const PAGE_CONCURRENCY = 1; export const TILE_TIMEOUT_MS = 20_000; export const PHOTO_API_TIMEOUT_MS = 15_000; -/** A sequence page is about a megabyte of JSON. */ +/** A sequence page is about 650 kB of JSON. */ export const PAGE_TIMEOUT_MS = 30_000; +/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */ +export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; /** A photo download (tens of kilobytes to a few megabytes). */ export const IMAGE_TIMEOUT_MS = 15_000; /** Whether a photo file is there (not archived): headers only. */ @@ -83,17 +86,18 @@ export const NEAREST_SEARCH_ZOOM = 18; /** * Photos of a sequence fetched at once, around the open one. Records are - * ~2.4 kB each, so a page is ~1.2 MB; the cones and ‹ › use it. Pages are in + * ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in * photo-id order, which only roughly follows the sequence index. */ -export const SEQUENCE_PAGE_SIZE = 500; +export const SEQUENCE_PAGE_SIZE = 250; /** Within this many photos of a page's edge, its neighbour page joins it. */ export const SEQUENCE_PAGE_EDGE = 12; /** Equirectangular panorama sizes: a quick preview, then the sharp one. */ export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); -/** A flat photo's sharp version, after the 1280 px one is on screen. */ +/** A flat photo's first version, then its sharp one. */ +export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]); export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); /** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */ @@ -149,24 +153,46 @@ function positionOf(record) { const STORAGE_HOST = /^storage\d+$/; +/** A KartaView photo-storage address, or null for anything else. */ +function storageUrl(value) { + try { + const url = new URL(String(value)); + return url.protocol === 'https:' && + /^storage\d+\.openstreetcam\.org$/.test(url.hostname) + ? url.href + : null; + } catch { + return null; + } +} + /** * A photo file's address. The API's `fileurl*` fields always name storage1, * but a photo whose `storage` is another host (storage2…) is only there, so - * the address is built from `storage` and the relative `filepath*`. + * the address is built from `storage` and the relative `filepath*`. A + * `fileurl*` fallback must itself be on KartaView's storage. */ function fileUrl(record, pathKey, urlKey) { const path = record[pathKey]; if (STORAGE_HOST.test(record.storage || '') && path) - return `https://${record.storage}.openstreetcam.org/files/photo/${path}`; - return record[urlKey] || null; + return storageUrl( + `https://${record.storage}.openstreetcam.org/files/photo/${path}`, + ); + return storageUrl(record[urlKey]); } /** A storage URL with its `{{sizeprefix}}` placeholder filled. */ function sized(record, prefix) { - const url = fileUrl(record, 'filepath', 'fileurl') || ''; - return url.includes('{{sizeprefix}}') - ? url.replace('{{sizeprefix}}', prefix) - : null; + const fill = (value) => + typeof value === 'string' && value.includes('{{sizeprefix}}') + ? value.replace('{{sizeprefix}}', prefix) + : null; + const path = fill(record.filepath); + return fileUrl( + { ...record, filepath: path, fileurl: fill(record.fileurl) }, + 'filepath', + 'fileurl', + ); } /** diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 140a3903c..8052a0d20 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -68,6 +68,18 @@ test('photo files come from the storage host the record names, not the one its U Buffer.from(link.split('/').at(-1), 'base64url').toString(), photo.urls.full, ); + // A fallback address must be on KartaView's storage, over https. + for (const fileurlLTh of [ + 'https://tracker.example/x.jpg', + 'http://storage1.openstreetcam.org/a.jpg', + 'javascript:alert(1)', + ]) { + const hostile = normalizePhoto( + record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }), + ); + assert.notEqual(hostile.urls.view, fileurlLTh); + assert.doesNotMatch(String(hostile.urls.view), /tracker|javascript|^http:/); + } // An unexpected storage value falls back to the URLs as given. const odd = normalizePhoto(record({ storage: 'https://evil.example' })); assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 582e32551..000d4458b 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createKartaViewProvider, kartaViewZoomFor } from './index.js'; +import { + createKartaViewProvider, + createTileCache, + kartaViewZoomFor, +} from './index.js'; import { SEQUENCE_PAGE_SIZE } from './policy.js'; import { validateProviders } from '../../registry.js'; import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; @@ -46,7 +50,8 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { calls.photo.push(id); for (const [seq, indexes] of Object.entries(sequences)) for (const index of indexes) - if (`${seq}-${index}` === id) return photoRecord(id, { seq, index }); + if (`${seq}-${index}` === id) + return photoRecord(id, { seq, index, metres: index * 5 }); throw new Error('KartaView has no such photo'); }, getSequencePhotos: async (seq, { page, itemsPerPage }) => { @@ -58,7 +63,7 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { ); return { photos: rows.map((index) => - photoRecord(`${seq}-${index}`, { seq, index }), + photoRecord(`${seq}-${index}`, { seq, index, metres: index * 5 }), ), hasMore: page * itemsPerPage < indexes.length, }; @@ -161,8 +166,9 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing await instance.viewer.open(id); assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache'); assert.equal( - loads[0], - 'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg', + Buffer.from(loads[0].split('/').at(-1), 'base64url').toString(), + 'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/near.jpg', + 'the proxy shrinks the photo on its real storage host', ); const none = setup({ nearest: [photoRecord('far', { metres: 80 })] }); assert.equal( @@ -321,3 +327,131 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a 'a record with no file is not a photo', ); }); + +/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */ +async function withCones(t, env) { + const Cesium = await import('cesium'); + const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); + const saved = globalThis.document; + globalThis.document = { + createElement: () => ({ getContext: () => drawing }), + }; + t.after(() => (globalThis.document = saved)); + let collection = null; + env.instance.init({ + scene: { + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + primitives: { add: (c) => (collection = c), remove() {} }, + preRender: { addEventListener: () => () => {} }, + }, + camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, + }); + return () => { + const indexes = []; + for (let i = 0; i < collection.length; i++) + indexes.push(Number(collection.get(i).id.split('-').at(-1))); + return [Math.min(...indexes), Math.max(...indexes)]; + }; +} + +test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const settleLong = async () => { + for (let i = 0; i < 40; i++) await settle(); + }; + await env.instance.viewer.open('s-10'); + await env.instance.selectSequence('s'); + await settleLong(); + assert.ok(cones()[1] < size, `first page: ${cones()}`); + // A line click further along the same drive: the core does not reselect. + await env.instance.viewer.open(`s-${size * 4 + 10}`); + await settleLong(); + assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`); + // Closed and reopened on another page: not the sequence's cached cones. + env.instance.clearSequence(); + await env.instance.viewer.open(`s-${size * 2 + 10}`); + await env.instance.selectSequence('s'); + await settleLong(); + assert.ok( + cones()[0] <= size * 2 + 10 && cones()[1] >= size * 2 + 10, + `around the photo: ${cones()}`, + ); +}); + +test('a storage hiccup is not remembered as an archived photo', async () => { + const env = setup({ nearest: [photoRecord('near', { metres: 3 })] }); + let answer = null; + env.source.probeImage = async () => answer; + // No answer: the photo is tried rather than called archived. + assert.equal( + await env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + 'near', + ); + answer = false; + await assert.rejects( + env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }), + /archived/, + 'asked again, and now it is gone', + ); +}); + +test('a photo far past a run of deleted photos still finds its page and neighbours', async () => { + const indexes = [ + ...Array.from({ length: 1000 }, (_, i) => i), + ...Array.from({ length: 20 }, (_, i) => 6000 + i), + ]; + const { instance, poses, next, prev } = await mounted({ + sequences: { s: indexes }, + }); + await instance.viewer.open('s-6005'); + for (let i = 0; i < 12; i++) await settle(); + assert.equal(next.disabled, false); + assert.equal(prev.disabled, false); + await press(next); + assert.equal(poses.at(-1), 's-6006'); +}); + +test('coverage tiles are kept for a while: panning back asks KartaView nothing', async () => { + let now = 0; + const asked = []; + const tiles = createTileCache({ + load: async (tile) => { + asked.push(`${tile.z}/${tile.x}/${tile.y}`); + return { sequences: [], overview: [] }; + }, + size: 2, + ttlMs: 1000, + now: () => now, + }); + const a = { x: 1, y: 2, z: 14 }; + const b = { x: 2, y: 2, z: 14 }; + const c = { x: 3, y: 2, z: 14 }; + const first = await tiles.get(a, {}); + assert.equal(await tiles.get(a, {}), first, 'the same decoded tile'); + await tiles.get(b, {}); + await tiles.get(c, {}); + await tiles.get(a, {}); + assert.deepEqual( + asked, + ['14/1/2', '14/2/2', '14/3/2', '14/1/2'], + 'oldest dropped', + ); + now = 2000; + await tiles.get(a, {}); + assert.equal(asked.length, 5, 'stale after the TTL'); + // A failed load is not kept. + const failing = createTileCache({ + load: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + size: 2, + ttlMs: 1000, + }); + await assert.rejects(failing.get(a, {}), /did not answer/); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 244ea38d8..aaf684aba 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -1,5 +1,6 @@ import { IMAGE_PROBE_TIMEOUT_MS, + MAX_RESPONSE_BYTES, KARTAVIEW_API_HOST, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, @@ -25,6 +26,8 @@ export class KartaViewSourceError extends Error { } const NO_ANSWER = 'KartaView did not answer in time — try again'; +/** Statuses that mean a photo file is gone, not that storage hiccupped. */ +const GONE_STATUSES = Object.freeze([403, 404, 409, 410]); /** Resolves after `ms`, or rejects when `signal` aborts first. */ function wait(ms, signal) { @@ -50,13 +53,18 @@ function retryAfterSeconds(response) { return Number.isFinite(value) && value >= 0 ? value : null; } -/** A first-in, first-out gate letting at most `limit` tasks run at once. */ -export function createLimiter(limit) { +/** + * A gate letting at most `limit` tasks run at once, first in first out, or + * newest first with `lifo` (the last request is the one the user waits on). + */ +export function createLimiter(limit, { lifo = false } = {}) { let running = 0; const queue = []; function next() { while (running < limit && queue.length) { - const { task, resolve, reject, signal } = queue.shift(); + const { task, resolve, reject, signal } = lifo + ? queue.pop() + : queue.shift(); if (signal?.aborted) { reject(signal.reason); continue; @@ -111,7 +119,7 @@ export function createKartaViewSource({ } = {}) { const limiter = createLimiter(PHOTO_API_CONCURRENCY); const tileLimiter = createLimiter(TILE_CONCURRENCY); - const pageLimiter = createLimiter(PAGE_CONCURRENCY); + const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true }); /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ async function getJson(url, { signal, timeoutMs }) { @@ -135,6 +143,11 @@ export function createKartaViewSource({ `KartaView answered HTTP ${response.status}`, { status: response.status }, ); + const length = Number(response.headers?.get?.('content-length')); + if (length > MAX_RESPONSE_BYTES) + throw new KartaViewSourceError('KartaView sent too much data', { + status: response.status, + }); return await response.json(); } catch (error) { if (signal?.aborted) throw signal.reason; @@ -203,19 +216,27 @@ export function createKartaViewSource({ return { /** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */ async getTile({ x, y, z }, { signal } = {}) { - return tileLimiter.run( - () => - getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, { - signal, - timeoutMs: TILE_TIMEOUT_MS, - }), - { signal }, - ); + const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`; + const once = () => + tileLimiter.run( + () => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }), + { signal }, + ); + try { + return await once(); + } catch (error) { + // A cold dense tile can outlast the timeout; KartaView has it ready + // by the second ask. + if (signal?.aborted || error?.message !== NO_ANSWER) throw error; + return once(); + } }, /** - * Whether a photo file can be fetched. Archived photos answer 409 (and - * a HEAD says 200 regardless), so this GETs and stops at the headers. + * Whether a photo file is there: true, false when it is gone (archived + * photos answer 409, and a HEAD says 200 regardless), or null when the + * storage did not say (timeout, network, 5xx). It GETs and stops at the + * headers. */ async probeImage(url, { signal } = {}) { if (!url) return false; @@ -229,10 +250,11 @@ export function createKartaViewSource({ ); try { const response = await fetchImpl(url, { signal: controller.signal }); - return response.ok; + if (response.ok) return true; + return GONE_STATUSES.includes(response.status) ? false : null; } catch { if (signal?.aborted) throw signal.reason; - return false; + return null; } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 73fa3b1bb..5805672a4 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -282,8 +282,67 @@ test('an image probe reads only the status: an archived photo (409) is not showa throw new TypeError('Failed to fetch'); }, }); + // No answer is not an answer: the caller may try again later. assert.equal( await offline.probeImage('https://x.openstreetcam.org/a.jpg'), - false, + null, + ); + const busy = createKartaViewSource({ + fetchImpl: async () => ({ ok: false, status: 502 }), + }); + assert.equal( + await busy.probeImage('https://x.openstreetcam.org/a.jpg'), + null, ); }); + +test('a cold tile that times out is asked once more', async () => { + let calls = 0; + const source = createKartaViewSource({ + fetchImpl: async () => { + calls++; + if (calls === 1) throw new TypeError('Failed to fetch'); + return ok({ lines: [] }); + }, + }); + assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); + assert.equal(calls, 2); +}); + +test('an absurdly large answer is refused before it is parsed', async () => { + let parsed = false; + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map([['content-length', String(64 * 1024 * 1024)]]), + json: async () => { + parsed = true; + return { lines: [] }; + }, + }), + }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/); + assert.equal(parsed, false); +}); + +test('sequence pages run one at a time, the newest first', async () => { + const order = []; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const source = createKartaViewSource({ + fetchImpl: async (url) => { + const page = new URL(url).searchParams.get('page'); + order.push(page); + if (page === '1') await gate; + return ok(photoPayload([])); + }, + }); + const pages = ['1', '2', '3'].map((page) => + source.getSequencePhotos('9', { page, itemsPerPage: 250 }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + release(); + await Promise.all(pages); + assert.deepEqual(order, ['1', '3', '2']); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index d3fc218e7..e10787b72 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -5,6 +5,7 @@ import { } from './panorama.js'; import { FLAT_FULL_SIZE, + FLAT_VIEW_SIZE, IMAGE_TIMEOUT_MS, KARTAVIEW_PROVIDER_ID, PANO_FULL_SIZE, @@ -50,6 +51,7 @@ export function loadImage( * @param {(photoId: string) => Promise} options.getPhoto A normalized photo (policy.js normalizePhoto). * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). + * @param {(photo: object) => void} [options.onShown] A photo is on screen. * @param {object} [options.render] Render governor. * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] @@ -59,6 +61,7 @@ export function createKartaViewViewer({ getPhoto, step, onStep, + onShown = () => {}, render, load = loadImage, createRenderer = createPanoramaRenderer, @@ -150,12 +153,18 @@ export function createKartaViewViewer({ ]) step(target, delta).then( (id) => { - if (photo === target && button && !id) button.disabled = true; + if (photo === target && button && !id) disable(button); }, () => {}, ); } + /** Disable a step button, keeping keyboard focus in the viewer. */ + function disable(button) { + if (button.ownerDocument?.activeElement === button) root?.focus?.(); + button.disabled = true; + } + function show(kind) { showing = kind; if (picture) picture.hidden = kind !== 'flat'; @@ -180,6 +189,26 @@ export function createKartaViewViewer({ return renderer; } + /** + * A lost WebGL context (GPU reset, memory pressure) takes the panorama + * with it: drop the renderer, and show the photo again on a new one. + */ + function onContextLost(event) { + event.preventDefault?.(); + const lost = renderer; + renderer = null; + try { + lost?.destroy(); + } catch { + /* already gone */ + } + if (showing === 'pano' && photo) { + const again = photo.id; + show(null); + adapter.open(again).catch(() => {}); + } + } + /** * Step along the sequence from the photo showing, or from the one on its * way after an earlier press, and open it through the core. @@ -194,13 +223,14 @@ export function createKartaViewViewer({ from = await getPhoto(fromId); if (from.sequenceId) nextId = await step(from, delta); } catch { - nextId = null; + // Not the end of the sequence, just no answer: the press is lost. + return; } // Closed, or another photo was opened meanwhile. if ((openingId ?? photo?.id) !== fromId) return; if (!nextId) { const button = delta < 0 ? prevButton : nextButton; - if (button && photo?.id === fromId) button.disabled = true; + if (button && photo?.id === fromId) disable(button); return; } openingId = nextId; @@ -209,20 +239,42 @@ export function createKartaViewViewer({ }); } + /** Pointers on the panorama: one drags the view, two pinch the zoom. */ + const pointers = new Map(); + let pinch = null; + + function pinchSpan() { + const [a, b] = [...pointers.values()]; + return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)); + } + function onPointerDown(event) { - if (showing !== 'pano' || event.button !== 0) return; + if (showing !== 'pano' || !renderer) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + canvas.setPointerCapture?.(event.pointerId); + if (pointers.size === 2) { + drag = null; + pinch = { span: pinchSpan(), fov: renderer.getView().fov }; + return; + } drag = { id: event.pointerId, x: event.clientX, y: event.clientY, view: renderer.getView(), }; - canvas.setPointerCapture?.(event.pointerId); canvas.classList.add('sl-kv-dragging'); } function onPointerMove(event) { - if (!drag || event.pointerId !== drag.id) return; + if (pointers.has(event.pointerId)) + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (pinch && pointers.size === 2 && renderer) { + renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) }); + return; + } + if (!drag || event.pointerId !== drag.id || !renderer) return; const { fov } = drag.view; const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); @@ -236,13 +288,15 @@ export function createKartaViewViewer({ } function onPointerUp(event) { + pointers.delete(event.pointerId); + if (pointers.size < 2) pinch = null; if (!drag || event.pointerId !== drag.id) return; drag = null; canvas.classList.remove('sl-kv-dragging'); } function onWheel(event) { - if (showing !== 'pano') return; + if (showing !== 'pano' || !renderer) return; event.preventDefault(); const { fov } = renderer.getView(); renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) }); @@ -256,9 +310,30 @@ export function createKartaViewViewer({ event.metaKey ) return; - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const pano = showing === 'pano' && renderer; + const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; + // Shift+← → look around a panorama; ← → step along the sequence. + if (turn && event.shiftKey && pano) { event.preventDefault(); - stepBy(event.key === 'ArrowLeft' ? -1 : 1); + const { yaw, fov } = renderer.getView(); + renderer.setView({ yaw: yaw + (turn * fov) / 4 }); + emitSoon(); + } else if (turn) { + event.preventDefault(); + stepBy(turn); + } else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) { + event.preventDefault(); + const { pitch, fov } = renderer.getView(); + renderer.setView({ + pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6, + }); + emitSoon(); + } else if (['+', '=', '-', '_'].includes(event.key) && pano) { + event.preventDefault(); + const { fov } = renderer.getView(); + renderer.setView({ + fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85), + }); } } @@ -277,7 +352,7 @@ export function createKartaViewViewer({ root.setAttribute('role', 'group'); root.setAttribute( 'aria-label', - 'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence', + 'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom', ); picture = doc.createElement('img'); picture.className = 'sl-kv-photo'; @@ -310,11 +385,14 @@ export function createKartaViewViewer({ listen(canvas, 'pointerup', onPointerUp); listen(canvas, 'pointercancel', onPointerUp); listen(canvas, 'wheel', onWheel, { passive: false }); + listen(canvas, 'webglcontextlost', onContextLost); listen(root, 'keydown', onKeyDown); applyRenderMode(); } function teardown() { + pointers.clear(); + pinch = null; for (const remove of removeListeners) remove(); removeListeners = []; if (poseFrame) cancelAnimationFrame(poseFrame); @@ -355,25 +433,36 @@ export function createKartaViewViewer({ resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), { crossOrigin: true }, ); - if (seq !== openSeq) return false; + if (seq !== openSeq || !renderer) return false; if (!keepView || showing !== 'pano') renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); - renderer.setImage(image); photo = target; + // Shown first: a hidden canvas measures 0 and would be sized 1×1. show('pano'); + renderer.setImage(image); return true; } async function showFlat(target, seq) { + // The proxy's 1,280 px version is a few hundred kB; KartaView's own + // "large thumbnail" can be 1.7 MB. Either serves when the other fails. + // A 360° photo's front view is only on storage. + const proxied = target.isPano + ? null + : resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE); + const sources = [proxied, target.urls.view].filter(Boolean); let image; - try { - image = await load(target.urls.view, { crossOrigin: false }); - } catch (error) { - // No 1280 px rendition: the proxy can shrink the full-size photo. - const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); - if (target.isPano || !fallback || seq !== openSeq) throw error; - image = await load(fallback, { crossOrigin: false }); + let failure = null; + for (const url of sources) { + try { + image = await load(url, { crossOrigin: false }); + break; + } catch (error) { + failure = error; + if (seq !== openSeq) return false; + } } + if (!image) throw failure || new Error(LOAD_FAILED); if (seq !== openSeq) return false; picture.src = image.src; photo = target; @@ -396,6 +485,7 @@ export function createKartaViewViewer({ } else shown = await showFlat(target, seq); if (!shown) return; emit(); + onShown(target); upgrade(target, seq, showing); } @@ -404,6 +494,7 @@ export function createKartaViewViewer({ if (!host) throw new Error('Street Level viewer has no host element'); if (root && root.parentNode === host) return; teardown(); + rendererFailed = false; build(host); }, diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 7372dea6c..28312a04a 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -51,12 +51,15 @@ const PHOTOS = { }, }; -function fakeRenderer() { +function fakeRenderer(canvas) { const view = { yaw: 0, pitch: 0, fov: 80 }; return { images: [], + hiddenAtSetImage: [], setImage(image) { this.images.push(image.src); + // A hidden canvas measures 0×0: sizing it then gives a 1×1 buffer. + this.hiddenAtSetImage.push(canvas?.hidden); }, setView(next) { Object.assign(view, next); @@ -70,7 +73,12 @@ function fakeRenderer() { }; } -function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { +function setup({ + failLoads = [], + rendererThrows = false, + steps = {}, + failSteps = false, +} = {}) { const loads = []; const renderers = []; const stepped = []; @@ -79,7 +87,10 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { if (!PHOTOS[id]) throw new Error('no such photo'); return PHOTOS[id]; }, - step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null, + step: async (photo, delta) => { + if (failSteps) throw new Error('KartaView did not answer in time'); + return steps[`${photo.id}:${delta}`] ?? null; + }, // The core opens what ‹ › chose; here the adapter is opened directly. onStep: (id) => { stepped.push(id); @@ -91,9 +102,9 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) { throw new Error('The KartaView photo could not be loaded'); return { src: url }; }, - createRenderer: () => { + createRenderer: (canvas) => { if (rendererThrows) throw new Error('WebGL is unavailable'); - const renderer = fakeRenderer(); + const renderer = fakeRenderer(canvas); renderers.push(renderer); return renderer; }, @@ -149,7 +160,11 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa }, ); assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//); - assert.equal(loads[0].url, PHOTOS.flat1.urls.view); + // The proxy's 1,280 px version first: KartaView's own can be 1.7 MB. + assert.match( + loads[0].url, + /^https:\/\/cdn\.kartaview\.org\/.*rs:fit:1280:1280/, + ); assert.equal(loads[0].crossOrigin, false, 'an needs no CORS'); await settle(); assert.match( @@ -162,12 +177,13 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa assert.equal(picture.style.objectFit, 'cover'); }); -test('a missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => { - const { viewer, host, loads } = setup({ failLoads: ['/lth/'] }); +test('a failing image proxy falls back to the photo on KartaView storage', async () => { + const { viewer, host, loads } = setup({ failLoads: ['cdn.kartaview.org'] }); await viewer.mount(host); await viewer.open('flat1'); - assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(loads[1].url, PHOTOS.flat1.urls.view); assert.equal(parts(host).picture.hidden, false); + assert.equal(parts(host).picture.src, PHOTOS.flat1.urls.view); }); test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => { @@ -291,3 +307,120 @@ test('a newer open wins; close hides the photo; unmount tears down the panorama' assert.equal(renderers[0].destroyed, true); await assert.rejects(viewer.open('flat1'), /not mounted/); }); + +test('a panorama is put on screen before its image is sized into the canvas', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('flat1'); + await viewer.open('pano1'); + await settle(); + assert.deepEqual(renderers[0].hiddenAtSetImage, [false, false]); +}); + +test('a step that gets no answer is not the end of the sequence', async () => { + const { viewer, host, poses } = setup({ failSteps: true }); + await viewer.mount(host); + await viewer.open('flat1'); + const { next } = parts(host); + next.disabled = false; + next.fire('click'); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(next.disabled, false, 'still enabled: try again'); + assert.equal(poses.at(-1).imageId, 'flat1'); +}); + +test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, stepped } = setup({ + steps: { 'pano1:1': 'flat1' }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + const { root } = parts(host); + const view = () => renderers[0].getView(); + root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); + assert.ok(view().yaw > 0, 'turned right'); + root.fire('keydown', { key: 'ArrowUp' }); + assert.ok(view().pitch > 0, 'looked up'); + const before = view().fov; + root.fire('keydown', { key: '+' }); + assert.ok(view().fov < before, 'zoomed in'); + root.fire('keydown', { key: '-' }); + root.fire('keydown', { key: '-' }); + assert.ok(view().fov > before, 'zoomed out'); + for (let i = 0; i < 4; i++) await settle(); + assert.deepEqual(stepped, [], 'none of those stepped'); + root.fire('keydown', { key: 'ArrowRight' }); + for (let i = 0; i < 4; i++) await settle(); + assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps'); +}); + +test('two fingers pinch the panorama’s zoom', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const { canvas } = parts(host); + const before = renderers[0].getView().fov; + canvas.fire('pointerdown', { + pointerId: 1, + pointerType: 'touch', + clientX: 100, + clientY: 100, + }); + canvas.fire('pointerdown', { + pointerId: 2, + pointerType: 'touch', + clientX: 140, + clientY: 100, + }); + canvas.fire('pointermove', { + pointerId: 2, + pointerType: 'touch', + clientX: 180, + clientY: 100, + }); + assert.ok(renderers[0].getView().fov < before, 'spread zooms in'); + canvas.fire('pointerup', { pointerId: 1 }); + canvas.fire('pointerup', { pointerId: 2 }); +}); + +test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => { + const { viewer, host, renderers, poses } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + parts(host).canvas.fire('webglcontextlost'); + assert.equal(renderers[0].destroyed, true); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(renderers.length, 2, 'a new renderer'); + assert.equal(poses.at(-1).imageId, 'pano1'); + assert.equal(parts(host).canvas.hidden, false); +}); + +test('a WebGL failure only lasts until the viewer is mounted again', async () => { + let fail = true; + const built = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep() {}, + load: async (url) => ({ src: url }), + createRenderer: (canvas) => { + if (fail) throw new Error('WebGL is unavailable'); + const renderer = fakeRenderer(canvas); + built.push(renderer); + return renderer; + }, + }); + const host = fakeHost(); + await viewer.mount(host); + await viewer.open('pano1'); + assert.equal(parts(host).picture.hidden, false, 'front view'); + viewer.unmount(); + fail = false; + const again = fakeHost(); + await viewer.mount(again); + await viewer.open('pano1'); + assert.equal(built.length, 1); + assert.equal(parts(again).canvas.hidden, false); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 2607ab542..68fff8444 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -22,8 +22,8 @@ export function filtersOf(provider) { * @property {boolean} [defaultOn] Default true. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). - * @property {{noun: string, action: string}} [coverageLabel] - * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). + * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). + * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -67,6 +67,7 @@ export function filtersOf(provider) { * @property {() => void} resize * @property {(host: HTMLElement) => Promise} [prewarm] * @property {(mode: 'letterbox'|'fill') => void} [setRenderMode] + * @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide. * @property {(listener: (pose: StreetPose) => void) => () => void} onPose * * @typedef {object} StreetPose From 3d4c6de21a72eb54bf22adea46c11022e90e2da3 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 20:43:27 +0100 Subject: [PATCH 23/78] =?UTF-8?q?test(street-level):=20harden=20KartaView?= =?UTF-8?q?=20tests=20(mutation=20score=2051%=20=E2=86=92=2095%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation review (91, then 130 mutants) found tests that could not fail (quick presses with synchronous fakes, a no-flight check with a provider that never posed), and untested paths. New and rewritten tests kill 123 of 130 mutants; the 7 survivors are equivalent or harmless redraws. - viewer: held-back loads for quick presses, stale sharp/flat loads, close mid-load, ends checks for an older photo, WebGL retried per mount, focus handed back, look direction kept on ›. - provider: id-ordered pages with out-of-order indexes, page and probe failures retried, overtaken lookups, cones re-centred on ‹ ›, leftover imagery filters hide no line or cone, coverage hints, tile cache. - source: 429 backoff and Retry-After under mock timers, hang timeouts, the nearest query's parameters. policy: storage-host injection, FOV 360, longitudes past ±180. panorama: a stub WebGL context (angles, lost context, re-measuring). lineCoverage.test.mjs: tooHigh hint, passes(). - core: openImage({frame:false}) and openNearest leave the camera (with a posing provider and a default-open control), KartaView's legend and wording end to end; viewerHost supportsRenderModes. - Fixed tick-count waits replaced by an until() poll. - Browser gate fixture: odd photos archived (409 at storage2, served by the proxy), the first lookup rate-limited (429, Retry-After: 0); the line-click step asserts the camera stays and › skips the archived photo. Co-Authored-By: Claude Opus 5.5 --- scripts/fixtures/street-level/kartaView.mjs | 41 +- scripts/format-scope.json | 1 + scripts/qa-street-level.mjs | 46 ++- src/layers/streetLevel/index.test.mjs | 63 ++- src/layers/streetLevel/lineCoverage.test.mjs | 142 +++++++ .../providers/kartaview/panorama.test.mjs | 105 +++++ .../providers/kartaview/policy.test.mjs | 68 +++- .../providers/kartaview/provider.test.mjs | 365 ++++++++++++++++-- .../providers/kartaview/source.test.mjs | 93 +++++ .../providers/kartaview/viewer.test.mjs | 354 +++++++++++++++-- src/layers/streetLevel/viewerHost.test.mjs | 14 + src/testSupport/kartaViewFakes.mjs | 13 + src/testSupport/streetLevelFakes.mjs | 94 +++++ src/tooling/streetLevelQa.test.mjs | 56 ++- 14 files changed, 1374 insertions(+), 81 deletions(-) create mode 100644 src/layers/streetLevel/lineCoverage.test.mjs diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index ccc7564e2..559c6a465 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -6,7 +6,11 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; * the gate's KartaView steps need no network. One drive runs north along * KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while * their `fileurl*` name storage1, as KartaView's API does, so the gate also - * proves the provider builds addresses from `storage`. + * proves the provider builds addresses from `storage`. Every odd-indexed + * photo is archived (storage answers 409, the image proxy still serves it), + * so the nearest photo opened is even and › must skip the one after it. The + * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as + * KartaView does under load. */ export const KV_SEQUENCE_ID = '900001'; @@ -21,7 +25,15 @@ const SHOT = '2024-05-02 16:20:00.000'; const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); -/** The drive's photos, south to north; every tenth is 360°. */ +/** Lookups answered so far: the first one is rate-limited. */ +let nearestLookups = 0; + +/** Forget the lookups answered, so the next one is rate-limited again. */ +export function resetKartaViewFixture() { + nearestLookups = 0; +} + +/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */ export function kartaViewPhotos() { const step = KV_SPACING_M / METRES_PER_DEG_LAT; const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1; @@ -31,6 +43,7 @@ export function kartaViewPhotos() { lon: KV_LINE.lon, lat: Number((KV_LINE.south + i * step).toFixed(7)), isPano: i % 10 === 5, + archived: i % 2 === 1, })); } @@ -127,10 +140,16 @@ export function answerKartaViewRequest({ method, url }) { return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname) ? image : { ...raw(404, {}), known: false }; - if (address.hostname === 'storage2.openstreetcam.org') - return address.pathname.includes(KV_SEQUENCE_ID) - ? image - : { ...raw(404, {}), known: false }; + if (address.hostname === 'storage2.openstreetcam.org') { + if (!address.pathname.includes(KV_SEQUENCE_ID)) + return { ...raw(404, {}), known: false }; + const archived = photos.some( + (photo) => + photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`), + ); + // An archived photo's file is gone from storage: KartaView answers 409. + return archived ? { ...raw(409, {}), known: true } : image; + } if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname)) // The wrong host the API names: KartaView answers 404 there. return { ...raw(404, {}), known: false }; @@ -148,6 +167,16 @@ export function answerKartaViewRequest({ method, url }) { known: true, }; if (path === '/2.0/photo/') { + if (nearestLookups++ === 0) + return { + ...raw(429, {}), + headers: { + ...CORS, + 'Retry-After': '0', + 'Access-Control-Expose-Headers': 'Retry-After', + }, + known: true, + }; const q = address.searchParams; const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; const near = photos diff --git a/scripts/format-scope.json b/scripts/format-scope.json index dbfa055ea..a77fc11f9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -921,6 +921,7 @@ "src/layers/streetLevel/providers/kartaview/panorama.test.mjs", "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", "server/providers/mapillary.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 60cb7f379..7c8a92b30 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -850,7 +850,7 @@ async function main() { }, ); await step( - 'KartaView: a click on a line opens the nearest photo, cones follow its drive, and › steps along it', + 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { const along = Array.from({ length: 9 }, (_, i) => [ KV_LINE.lon, @@ -871,6 +871,17 @@ async function main() { if (!handle && !fixtures) return skip('no KartaView line in view'); assert.ok(handle, 'a KartaView line is pickable'); const at = await handle.jsonValue(); + const camera = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + height: c.height, + }; + }); + const parked = await camera(); + // The first lookup is rate-limited (fixtures): it waits and retries. await page.mouse.click(at.x, at.y); await page.waitForFunction( () => { @@ -894,10 +905,22 @@ async function main() { }), 'the photo is on screen', ); + // A line click picks a spot: the camera stays where it was. + await sleep(1500); + const now = await camera(); + assert.ok( + Math.abs(now.lon - parked.lon) < 1e-6 && + Math.abs(now.lat - parked.lat) < 1e-6 && + Math.abs(now.height - parked.height) < 0.5, + `the camera did not move: ${JSON.stringify({ parked, now })}`, + ); if (fixtures) { - assert.ok( - photos.some((photo) => photo.id === street.imageId), - `${street.imageId} is a fixture photo`, + const opened = photos.find((photo) => photo.id === street.imageId); + assert.ok(opened, `${street.imageId} is a fixture photo`); + assert.equal( + opened.archived, + false, + 'the nearest photo that storage still has', ); assert.ok( await uiUntil( @@ -922,12 +945,21 @@ async function main() { ), '› opened the next photo', ); - if (fixtures) + if (fixtures) { + const skipped = photos.find( + (photo) => photo.id === String(Number(before) + 1), + ); + assert.equal( + skipped?.archived, + true, + 'the photo after is archived', + ); assert.equal( (await ui()).street.imageId, - String(Number(before) + 1), - 'the next photo along the drive', + String(Number(before) + 2), + '› skipped the archived photo to the next one along the drive', ); + } }, ); if (fixtures) diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index ccc8b9dfe..adbeaf0bc 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -4,6 +4,7 @@ import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; +import { createKartaViewProvider } from './providers/kartaview/index.js'; import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; /** @@ -92,8 +93,8 @@ function deferred() { } /** A provider whose viewer reports a pose for every image it opens. */ -function posingProvider() { - const provider = fakeProvider(); +function posingProvider(options) { + const provider = fakeProvider(options); const create = provider.create; provider.create = (context) => { const instance = create(context); @@ -838,3 +839,61 @@ test('a failed lookup’s error goes when another source is lit; a provider can assert.deepEqual(other.calls.open, ['img-1']); assert.equal(viewer.flights.started, 0); }); + +test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => { + const provider = posingProvider(); + const { layer, viewer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + await provider.context().actions.openImage('img-1', { frame: false }); + assert.deepEqual(provider.calls.open, ['img-1']); + assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera'); + await provider.context().actions.openImage('img-2'); + assert.equal(viewer.flights.started, 1, 'a default open frames the image'); +}); + +test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => { + const lit = posingProvider({ nearestImage: async () => 'lit-near' }); + const other = fakeProvider({ + id: 'other', + pickPrefix: 'oth:', + nearestImage: async () => 'other-near', + }); + const { layer, viewer } = await enabledLayer(t, [lit, other]); + layer.attachViewerHost({}); + const point = { lat: 38.58, lon: -121.49 }; + // The other source is off: its click asks nobody else. + await other.context().actions.openNearest(point); + await settle(); + assert.deepEqual(lit.calls.open, [], 'the lit source was not asked'); + assert.deepEqual(other.calls.open, []); + await lit.context().actions.openNearest(point); + assert.deepEqual(lit.calls.open, ['lit-near']); + assert.equal(viewer.flights.started, 0, 'the user picked the spot'); +}); + +test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => { + const kartaView = createKartaViewProvider({ + source: { + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => [], + getPhoto: async () => { + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async () => ({ photos: [], hasMore: false }), + probeImage: async () => true, + }, + }); + const { layer } = await enabledLayer(t, [kartaView]); + layer.setProviderEnabled('kartaview', true); + await settle(); + const ui = layer.getUIState(); + assert.deepEqual( + ui.legend.map((entry) => entry.key), + ['kartaview'], + ); + assert.equal(ui.coverage.label?.noun, 'streets'); + assert.equal( + ui.providers.find((p) => p.id === 'kartaview').highlightsSequence, + false, + ); +}); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs new file mode 100644 index 000000000..88308673c --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLineCoverage } from './lineCoverage.js'; +import { + coverageViewer, + tileCentre, +} from '../../testSupport/streetLevelFakes.mjs'; +import { until } from '../../testSupport/kartaViewFakes.mjs'; + +const TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + maxSequences: 900, + lineWidthPx: 2, + overviewPointPx: 2, + moveDebounceMs: 10, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); + +/** + * Line coverage over one tile holding one line, on a viewer looking down from + * `height`. `fetchTile` answers with that line unless `fail` says otherwise. + */ +function setup( + t, + { zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {}, +) { + const centre = tileCentre(-121.4944, 38.5816); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height: 900 }, + t, + ); + const state = { + viewer, + services: {}, + keyRequired: false, + keyRejected: false, + context: { + getFilter: () => ({ pano: 'all', sinceMs: null }), + isActive: () => true, + notify() {}, + getSurface: () => 'draped', + groundCaster: null, + meshSampler: null, + }, + coverage: { + zoom: null, + kind: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: Promise.resolve(), + hint: '', + }, + sequence: { selectedId: null }, + }; + const asked = []; + const coverage = createLineCoverage({ + state, + fetchTile: async (tile) => { + asked.push(tile); + if (fail) throw fail; + return { + sequences: [ + { + id: 'line-1', + parts: [ + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], + ], + capturedAt: 0, + isPano: false, + }, + ], + overview: [], + }; + }, + zoomFor: + zoomFor ?? + ((height) => + Number.isFinite(height) && height <= 6_000 + ? { zoom: 14, kind: 'sequence' } + : null), + colors: { coverage: '#635bff', selected: '#00e5ff' }, + pickPrefix: 'test:line:', + renderKey: 'test-coverage', + tuning: TUNING, + messages, + ...(passes ? { passes } : {}), + }); + t.after(() => { + coverage.clear(); + coverage.resetErrors(); + }); + return { viewer, state, coverage, asked }; +} + +test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => { + const tooHigh = 'zoom in to see test coverage'; + const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } }); + high.viewer.view.height = 7_000; + high.coverage.refresh(); + assert.equal(high.state.coverage.hint, tooHigh); + assert.equal(high.state.coverage.zoom, null); + assert.equal(high.asked.length, 0, 'nothing is asked from up there'); + const generic = setup(t); + generic.viewer.view.height = 7_000; + generic.coverage.refresh(); + assert.notEqual(generic.state.coverage.hint, tooHigh); + assert.match(generic.state.coverage.hint, /Point the camera/); +}); + +test('a provider’s passes() decides which lines are drawn and counted', async (t) => { + const all = setup(t, { passes: () => true }); + all.coverage.refresh(); + assert.ok(await until(() => all.state.coverage.tiles.size === 1)); + assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn'); + const none = setup(t, { passes: () => false }); + none.coverage.refresh(); + assert.ok(await until(() => none.state.coverage.tiles.size === 1)); + assert.equal(none.coverage.sequenceCount(), 0, 'filtered out'); +}); + +test('a refused key without its own message keeps the error it came with', async (t) => { + const refused = Object.assign(new Error('the source refused the key'), { + keyRejected: true, + }); + const { state, coverage } = setup(t, { fail: refused }); + coverage.refresh(); + assert.ok(await until(() => state.keyRejected === true)); + assert.equal(state.coverage.lastError, 'the source refused the key'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs index eddf7de4b..8bb9c9d62 100644 --- a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -6,6 +6,7 @@ import { PANO_PITCH_LIMIT_DEG, clampFov, clampPitch, + createPanoramaRenderer, rayToUv, wrapDegrees, } from './panorama.js'; @@ -35,3 +36,107 @@ test('view limits: pitch stops short of the poles, field of view stays usable', assert.equal(wrapDegrees(-30), 330); assert.equal(wrapDegrees(725), 5); }); + +/** + * A WebGL stand-in that records draw calls and uniform values; `lost` makes + * it report a lost context. + */ +function stubGl() { + const gl = { + lost: false, + calls: [], + uniforms: {}, + isContextLost: () => gl.lost, + getShaderParameter: () => true, + getProgramParameter: () => true, + getUniformLocation: (program, name) => name, + getExtension: () => null, + uniform1f: (name, value) => (gl.uniforms[name] = value), + }; + return new Proxy(gl, { + get(target, key) { + if (key in target) return target[key]; + if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key; + return (...args) => { + target.calls.push(key); + return { key, args }; + }; + }, + }); +} + +/** A panorama on a stub canvas, with frames run when the test says. */ +function stubPanorama(t, { width = 400, height = 250 } = {}) { + const gl = stubGl(); + const canvas = { + clientWidth: width, + clientHeight: height, + width: 300, + height: 150, + getContext: () => gl, + }; + const frames = []; + const saved = { + request: globalThis.requestAnimationFrame, + cancel: globalThis.cancelAnimationFrame, + }; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + globalThis.cancelAnimationFrame = () => {}; + t.after(() => { + globalThis.requestAnimationFrame = saved.request; + globalThis.cancelAnimationFrame = saved.cancel; + }); + const renderer = createPanoramaRenderer(canvas); + const runFrames = () => { + while (frames.length) frames.shift()(); + }; + return { gl, canvas, renderer, runFrames }; +} + +const IMAGE = { width: 1024, height: 512 }; + +test('a panorama draws once it has an image; looking up and right are positive angles', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.render(); + runFrames(); + assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet'); + renderer.setImage(IMAGE); + renderer.setView({ yaw: 30, pitch: 30 }); + runFrames(); + assert.ok(gl.calls.includes('drawArrays')); + assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`); + assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9); + assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9); + renderer.setView({ pitch: -20 }); + runFrames(); + assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative'); +}); + +test('a lost WebGL context draws nothing', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.setImage(IMAGE); + gl.lost = true; + gl.calls.length = 0; + runFrames(); + assert.deepEqual( + gl.calls.filter((call) => + ['viewport', 'clear', 'drawArrays'].includes(call), + ), + [], + ); +}); + +test('the canvas is measured again when it draws, not only when its image arrived', (t) => { + // The image arrives while the canvas is still hidden (0×0)… + const { canvas, renderer, runFrames } = stubPanorama(t, { + width: 0, + height: 0, + }); + renderer.setImage(IMAGE); + assert.deepEqual([canvas.width, canvas.height], [1, 1]); + // …and is laid out before the frame draws. + canvas.clientWidth = 400; + canvas.clientHeight = 250; + runFrames(); + assert.deepEqual([canvas.width, canvas.height], [400, 250]); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 8052a0d20..c9ab2ca4b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -72,13 +72,17 @@ test('photo files come from the storage host the record names, not the one its U for (const fileurlLTh of [ 'https://tracker.example/x.jpg', 'http://storage1.openstreetcam.org/a.jpg', + 'https://storage1.openstreetcam.org.evil.example/a.jpg', 'javascript:alert(1)', ]) { const hostile = normalizePhoto( record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }), ); assert.notEqual(hostile.urls.view, fileurlLTh); - assert.doesNotMatch(String(hostile.urls.view), /tracker|javascript|^http:/); + assert.doesNotMatch( + String(hostile.urls.view), + /tracker|javascript|evil|^http:/, + ); } // An unexpected storage value falls back to the URLs as given. const odd = normalizePhoto(record({ storage: 'https://evil.example' })); @@ -171,3 +175,65 @@ test('resized images go through the proxy with a base64url address', () => { ); assert.equal(resizedImageUrl(null, [1, 1]), null); }); + +test('a storage value that only contains a storage host name builds no address from it', () => { + for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) { + const photo = normalizePhoto(record({ storage })); + // Not a storage host: the API's own storage URLs serve. + assert.equal( + photo.urls.view, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + assert.equal( + photo.urls.full, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + } +}); + +test('a 360° field of view makes a panorama whatever the projection says', () => { + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano, + true, + ); + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano, + false, + ); +}); + +test('a longitude past ±180 is not a position', () => { + assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null); + assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null); + assert.equal( + normalizePhoto(record({ lng: '180', matchLng: '180' })).lon, + 180, + ); +}); + +test('with no processed-photo path the size prefix gives the full photo', () => { + const photo = normalizePhoto( + record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }), + ); + assert.equal( + photo.urls.full, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + // Only the large thumbnail, no path at all: the thumbnail still shows. + const thumbOnly = normalizePhoto( + record({ + filepathProc: '', + fileurlProc: '', + filepath: '', + fileurl: '', + storage: 'storage3', + }), + ); + assert.equal(thumbOnly.urls.full, null); + assert.match( + thumbOnly.urls.view, + /^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 000d4458b..9c63e9c18 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -5,9 +5,17 @@ import { createTileCache, kartaViewZoomFor, } from './index.js'; -import { SEQUENCE_PAGE_SIZE } from './policy.js'; +import { + NO_COVERAGE_HINT, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, +} from './policy.js'; import { validateProviders } from '../../registry.js'; -import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; +import { + coverageViewer, + tileCentre, +} from '../../../../testSupport/streetLevelFakes.mjs'; /** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { @@ -71,13 +79,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { }; } -function fakeContext() { +function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { const actions = { opened: [], nearest: [], instance: null }; return { actions, context: { services: {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), + getFilter: () => filter, isActive: () => true, groundCaster: null, meshSampler: null, @@ -97,7 +105,7 @@ function fakeContext() { }; } -function setup(sourceOptions) { +function setup(sourceOptions, contextOptions) { const source = fakeSource(sourceOptions); const loads = []; const definition = createKartaViewProvider({ @@ -112,7 +120,7 @@ function setup(sourceOptions) { }, }, }); - const { context, actions } = fakeContext(); + const { context, actions } = fakeContext(contextOptions); const instance = definition.create(context); actions.instance = instance; return { definition, instance, source, actions, loads }; @@ -126,6 +134,11 @@ test('KartaView registers keyless, off by default, with no filters and its own w assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); assert.deepEqual(definition.filters, []); + assert.equal( + definition.highlightsSequence, + false, + 'its lines are streets: a selected drive shows as cones', + ); assert.match( definition.credit.html, /KartaView<\/a> contributors, CC BY-SA 4\.0/, @@ -147,6 +160,11 @@ test('coverage is drawn from z14 near the street and z13 a little higher; never assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.deepEqual( + kartaViewZoomFor(6_000), + { zoom: 13, kind: 'sequence' }, + 'the top of the range still draws', + ); assert.equal(kartaViewZoomFor(6_001), null); assert.equal(kartaViewZoomFor(NaN), null); }); @@ -206,13 +224,16 @@ test('coverage stats report KartaView as configured and key-free', async () => { assert.equal(stats.count, 0); }); -const press = async (button) => { +/** Press a step button and wait until it has opened a photo (`opened` grew). */ +const press = async (button, opened) => { + const before = opened.length; button.fire('click'); - for (let i = 0; i < 12; i++) await settle(); + await until(() => opened.length > before); + await settle(); }; -async function mounted(sourceOptions) { - const env = setup(sourceOptions); +async function mounted(sourceOptions, contextOptions) { + const env = setup(sourceOptions, contextOptions); const host = fakeHost(); await env.instance.viewer.mount(host); const poses = []; @@ -228,20 +249,26 @@ test('› walks across sequence pages without moving the camera; ‹ walks back; sequences: { s: indexes }, }); await instance.viewer.open(`s-${size - 1}`); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); assert.deepEqual(actions.opened.at(-1), { id: `s-${size}`, options: { frame: false }, }); assert.ok(source.calls.pages.includes('s:2')); - await press(prev); + await press(prev, poses); assert.equal(poses.at(-1), `s-${size - 1}`); await instance.viewer.open('s-0'); - for (let i = 0; i < 6; i++) await settle(); - assert.equal(prev.disabled, true, 'nothing before the first photo'); - await press(prev); - assert.equal(poses.at(-1), 's-0'); + assert.ok(await until(() => prev.disabled), 'nothing before the first photo'); + // ‹ at the start opens nothing: presses run in turn, so once a later › + // has opened its photo, a ‹ that opened one would show before it. + const before = actions.opened.length; + prev.fire('click'); + await press(next, poses); + assert.deepEqual( + actions.opened.slice(before).map((open) => open.id), + ['s-1'], + ); }); test('pages that overlap in index (id order) step one photo at a time across the edge', async () => { @@ -260,7 +287,7 @@ test('pages that overlap in index (id order) step one photo at a time across the sequences: { s: [...first, ...second] }, }); await instance.viewer.open(`s-${size - 5}`); - for (let k = 1; k <= 8; k++) await press(next); + for (let k = 1; k <= 8; k++) await press(next, poses); assert.deepEqual( poses.slice(-9).map((id) => Number(id.slice(2))), Array.from({ length: 9 }, (_, i) => size - 5 + i), @@ -278,14 +305,13 @@ test('a photo whose index outruns the page count is found, and its neighbours ar sequences: { s: indexes }, }); await instance.viewer.open('s-1500'); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-1501'); - await press(prev); - await press(prev); + await press(prev, poses); + await press(prev, poses); assert.equal(poses.at(-1), 's-499', 'back across the gap'); await instance.viewer.open('s-1999'); - for (let i = 0; i < 8; i++) await settle(); - assert.equal(next.disabled, true, 'the last photo'); + assert.ok(await until(() => next.disabled), 'the last photo'); }); test('archived photos are skipped: by the nearest lookup, and by › and ‹', async () => { @@ -302,7 +328,7 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a 'kept', ); await instance.viewer.open('s-1'); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-4', 'two archived photos stepped over'); const allGone = setup({ nearest: [photoRecord('gone', { metres: 3 })], @@ -348,12 +374,14 @@ async function withCones(t, env) { }, camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, }); - return () => { + const cones = () => { const indexes = []; for (let i = 0; i < collection.length; i++) indexes.push(Number(collection.get(i).id.split('-').at(-1))); return [Math.min(...indexes), Math.max(...indexes)]; }; + cones.count = () => collection?.length ?? 0; + return cones; } test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => { @@ -362,26 +390,27 @@ test('the cones follow the open photo to another page of a long sequence, cached sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, }); const cones = await withCones(t, env); - const settleLong = async () => { - for (let i = 0; i < 40; i++) await settle(); - }; + const drawnAround = (index) => + until( + () => + !env.instance.sequenceStats().loading && + cones.count() > 0 && + cones()[0] <= index && + cones()[1] >= index, + ); await env.instance.viewer.open('s-10'); await env.instance.selectSequence('s'); - await settleLong(); + assert.ok(await drawnAround(10)); assert.ok(cones()[1] < size, `first page: ${cones()}`); // A line click further along the same drive: the core does not reselect. await env.instance.viewer.open(`s-${size * 4 + 10}`); - await settleLong(); + assert.ok(await drawnAround(size * 4 + 10), `re-centred: ${cones()}`); assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`); // Closed and reopened on another page: not the sequence's cached cones. env.instance.clearSequence(); await env.instance.viewer.open(`s-${size * 2 + 10}`); await env.instance.selectSequence('s'); - await settleLong(); - assert.ok( - cones()[0] <= size * 2 + 10 && cones()[1] >= size * 2 + 10, - `around the photo: ${cones()}`, - ); + assert.ok(await drawnAround(size * 2 + 10), `around the photo: ${cones()}`); }); test('a storage hiccup is not remembered as an archived photo', async () => { @@ -406,14 +435,18 @@ test('a photo far past a run of deleted photos still finds its page and neighbou ...Array.from({ length: 1000 }, (_, i) => i), ...Array.from({ length: 20 }, (_, i) => 6000 + i), ]; - const { instance, poses, next, prev } = await mounted({ + const { instance, poses, next, prev, source } = await mounted({ sequences: { s: indexes }, }); await instance.viewer.open('s-6005'); - for (let i = 0; i < 12; i++) await settle(); + // Both ends checks have found a neighbour (and probed it) before the look. + const probed = (id) => + source.calls.probes.some((url) => url.endsWith(`/${id}.jpg`)); + assert.ok(await until(() => probed('s-6004') && probed('s-6006'))); + await settle(); assert.equal(next.disabled, false); assert.equal(prev.disabled, false); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-6006'); }); @@ -445,6 +478,10 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing', now = 2000; await tiles.get(a, {}); assert.equal(asked.length, 5, 'stale after the TTL'); + // The same x/y at another zoom is another tile. + await tiles.get({ ...a, z: 13 }, {}); + assert.deepEqual(asked.at(-1), '13/1/2'); + assert.equal(asked.length, 6); // A failed load is not kept. const failing = createTileCache({ load: async () => { @@ -455,3 +492,257 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing', }); await assert.rejects(failing.get(a, {}), /did not answer/); }); + +test('a page in id order, its indexes out of order, still steps in index order', async () => { + const { instance, poses, next, prev } = await mounted({ + sequences: { s: [0, 2, 1, 3, 4] }, + }); + await instance.viewer.open('s-0'); + await press(next, poses); + assert.equal(poses.at(-1), 's-1'); + await press(next, poses); + assert.equal(poses.at(-1), 's-2'); + await press(prev, poses); + assert.equal(poses.at(-1), 's-1'); +}); + +test('a sequence page that failed once is asked again: › then reaches the next photo', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3] } }); + const real = env.source.getSequencePhotos; + let fail = true; + env.source.getSequencePhotos = async (...args) => { + env.source.calls.pages.push('failing'); + if (fail) throw new Error('KartaView did not answer in time — try again'); + return real(...args); + }; + await env.instance.viewer.open('s-1'); + // The ends checks ask for the page and get no answer. + assert.ok(await until(() => env.source.calls.pages.includes('failing'))); + await settle(); + fail = false; + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-2'); +}); + +test('a lookup overtaken while it probes shares the probe with the next one, which finds the photo', async () => { + const env = setup({ nearest: [photoRecord('kept', { metres: 9 })] }); + let answer; + env.source.probeImage = (url) => { + env.source.calls.probes.push(url); + return new Promise((resolve) => (answer = resolve)); + }; + env.source.nearestPhotos = async (point, { signal } = {}) => { + signal?.throwIfAborted(); + return [photoRecord('kept', { metres: 9 })]; + }; + const point = { lat: 42.3314, lon: -83.0458 }; + const controller = new AbortController(); + const first = env.instance.nearestImage(point, { signal: controller.signal }); + assert.ok(await until(() => env.source.calls.probes.length === 1)); + controller.abort(new Error('overtaken')); + const second = env.instance.nearestImage(point); + await settle(); + answer(true); + assert.equal(await second, 'kept'); + await first.catch(() => {}); + assert.equal(env.source.calls.probes.length, 1, 'one probe, shared'); + // A definite answer is kept: the next lookup asks storage nothing. + assert.equal(await env.instance.nearestImage(point), 'kept'); + assert.equal(env.source.calls.probes.length, 1); + // A lookup aborted before KartaView answers gives up. + const late = new AbortController(); + late.abort(new Error('closed')); + await assert.rejects( + env.instance.nearestImage(point, { signal: late.signal }), + /closed/, + ); +}); + +test('a probe that fails outright is tried, and asked again next time', async () => { + const env = setup({ nearest: [photoRecord('near', { metres: 3 })] }); + let probe = async () => { + throw new TypeError('Failed to fetch'); + }; + env.source.probeImage = (url) => probe(url); + const point = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await env.instance.nearestImage(point), 'near'); + probe = async () => false; + await assert.rejects(env.instance.nearestImage(point), /archived/); +}); + +test('a photo past the last page asks for no page beyond it', async () => { + const env = await mounted({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + // KartaView knows photo 12, but no page lists it (moved or re-indexed). + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-12' + ? photoRecord('s-12', { seq: 's', index: 12, metres: 60 }) + : getPhoto(id); + await env.instance.viewer.open('s-12'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-9'); + assert.deepEqual([...new Set(env.source.calls.pages)], ['s:1']); +}); + +test('the cones re-centre when ‹ › walk off the page they were drawn from', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 3 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const settled = () => + until(() => !env.instance.sequenceStats().loading && cones.count() > 0); + // Near the start of page 2: the cones show page 2 alone. + await env.instance.viewer.open(`s-${size + 12}`); + await env.instance.selectSequence('s'); + assert.ok(await settled()); + assert.deepEqual(cones(), [size, size * 2 - 1]); + // ‹ back across the edge onto page 1. + for (let k = 0; k < 13; k++) await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), `s-${size - 1}`); + assert.ok( + await until(() => cones()[0] < size), + `drawn around page 1 now: ${cones()}`, + ); + // › forward, off the far end of page 2. + await env.instance.viewer.open(`s-${size * 2 - 13}`); + assert.ok(await until(() => cones()[0] >= size), `${cones()}`); + for (let k = 0; k < 13; k++) await press(env.next, env.poses); + assert.equal(env.poses.at(-1), `s-${size * 2}`); + assert.ok( + await until(() => cones()[1] >= size * 2), + `drawn around page 3 now: ${cones()}`, + ); +}); + +test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => { + const env = await mounted( + { sequences: { s: [0, 1, 2, 3] } }, + { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, + ); + // Undated records: no capture time to pass a "since" filter with. + const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => undated(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(undated) }; + }; + const cones = await withCones(t, env); + await env.instance.viewer.open('s-1'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.equal(env.instance.sequenceStats().images, 4); + assert.equal(cones.count(), 4, 'every cone drawn'); +}); + +/** + * The provider drawing coverage on a stand-in viewer above one z14 tile. + * `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held. + */ +function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { + const env = setup({}, { filter }); + const centre = tileCentre(-83.0458, 42.3314); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height }, + t, + ); + const tiles = []; + let hold = null; + env.source.getTile = (tile) => { + tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + const answer = { + lines: lines.map((track) => ({ + track: track.map(([lon, lat]) => ({ + lat: String(lat), + lng: String(lon), + })), + })), + }; + return hold ? hold.then(() => answer) : Promise.resolve(answer); + }; + env.instance.init(viewer); + t.after(() => env.instance.destroy(viewer)); + return { + ...env, + viewer, + centre, + tiles, + holdTiles() { + let release; + hold = new Promise((resolve) => (release = resolve)); + return () => { + hold = null; + release(); + }; + }, + }; +} + +test('a leftover imagery filter hides no KartaView line', async (t) => { + const { instance, viewer, centre } = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 }, + lines: [ + [ + [-83.0458 - 0.001, 42.3314], + [-83.0458 + 0.001, 42.3314], + ], + ], + }); + assert.ok(centre); + instance.activate(viewer); + assert.ok(await until(() => instance.coverageStats().count === 1)); + assert.equal(instance.coverageStats().hint, ''); +}); + +test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { + const env = coverageSetup(t, { height: 7_000 }); + env.instance.activate(env.viewer); + assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT); + assert.deepEqual(env.tiles, [], 'nothing asked from up there'); + const release = env.holdTiles(); + env.viewer.view.height = 900; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); + release(); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().count, 0); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); +}); + +test('a coverage tile loaded a moment ago is not asked for again', async (t) => { + const env = coverageSetup(t, { + lines: [ + [ + [-83.0458 - 0.001, 42.3314], + [-83.0458 + 0.001, 42.3314], + ], + ], + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + // Off and on again (or a pan away and back): drawn from the kept tile. + env.instance.deactivate(); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 1); +}); + +test('a photo no page lists steps to the photos either side of its index', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3, 4, 6, 7, 8, 9] } }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-5' + ? photoRecord('s-5', { seq: 's', index: 5, metres: 25 }) + : getPhoto(id); + await env.instance.viewer.open('s-5'); + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-6'); + await env.instance.viewer.open('s-5'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-4'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 5805672a4..bed8b2ec5 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -6,8 +6,11 @@ import { createLimiter, } from './source.js'; import { + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES, + PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, } from './policy.js'; @@ -346,3 +349,93 @@ test('sequence pages run one at a time, the newest first', async () => { await Promise.all(pages); assert.deepEqual(order, ['1', '3', '2']); }); + +/** Every pending promise callback, without moving the mocked clock. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Run `call` on the mocked clock in 100 ms steps until it settles (60 s at + * most). `stamp()` reads the clock, for a fetch to record when it was asked. + */ +async function onMockClock(t, call) { + let now = 0; + const stamp = () => now; + let outcome = null; + const promise = call(stamp); + promise.then( + (value) => (outcome = { value, at: now }), + (error) => (outcome = { error, at: now }), + ); + while (!outcome && now < 60_000) { + await flush(); + if (outcome) break; + t.mock.timers.tick(100); + now += 100; + } + await flush(); + return outcome; +} + +test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and 2 s without one', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + async function waits(retryAfter) { + const asked = []; + const headers = new Map( + retryAfter === undefined ? [] : [['retry-after', retryAfter]], + ); + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: async () => { + asked.push(stamp()); + return asked.length <= PHOTO_API_RETRIES + ? status(429, headers) + : ok(photoPayload({ id: '1' })); + }, + }).getPhoto('1'), + ); + assert.deepEqual(outcome?.value, { id: '1' }, `answered (${retryAfter})`); + return asked.slice(1).map((at, i) => at - asked[i]); + } + assert.deepEqual(await waits('0'), [800, 1600, 3200], 'doubling backoff'); + assert.deepEqual(await waits('5'), [5000, 5000, 5000], 'Retry-After wins'); + assert.deepEqual(await waits('30'), [10_000, 10_000, 10_000], 'capped'); + assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s'); +}); + +test('a lookup that hangs is given up after the photo timeout, once more, then reported', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const asked = []; + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + asked.push(stamp()); + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.deepEqual(asked, [0, PHOTO_API_TIMEOUT_MS]); + assert.match(String(outcome?.error?.message), /did not answer in time/); + assert.equal(outcome.at, 2 * PHOTO_API_TIMEOUT_MS); +}); + +test('the nearest lookup asks for the few photos around the point at the search zoom', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ lat: 42.33, lon: -83.04 }); + assert.equal(urls[0].pathname, '/2.0/photo/'); + assert.deepEqual(Object.fromEntries(urls[0].searchParams), { + lat: '42.3300000', + lng: '-83.0400000', + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + }); + assert.equal(NEAREST_SEARCH_ZOOM, 18); + assert.equal(NEAREST_LIMIT, 10); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 28312a04a..fd3242ef0 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createKartaViewViewer } from './viewer.js'; -import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; const PHOTOS = { flat1: { @@ -49,6 +49,21 @@ const PHOTOS = { pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', }, }, + pano2: { + id: 'pano2', + sequenceId: 's2', + sequenceIndex: 8, + lon: 106.8401, + lat: -6.19, + heading: 152, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/q.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + }, + }, }; function fakeRenderer(canvas) { @@ -82,12 +97,15 @@ function setup({ const loads = []; const renderers = []; const stepped = []; + const asked = []; + const attempts = { renderer: 0 }; const viewer = createKartaViewViewer({ getPhoto: async (id) => { if (!PHOTOS[id]) throw new Error('no such photo'); return PHOTOS[id]; }, step: async (photo, delta) => { + asked.push(`${photo.id}:${delta}`); if (failSteps) throw new Error('KartaView did not answer in time'); return steps[`${photo.id}:${delta}`] ?? null; }, @@ -103,6 +121,7 @@ function setup({ return { src: url }; }, createRenderer: (canvas) => { + attempts.renderer++; if (rendererThrows) throw new Error('WebGL is unavailable'); const renderer = fakeRenderer(canvas); renderers.push(renderer); @@ -112,7 +131,16 @@ function setup({ const poses = []; viewer.onPose((pose) => poses.push(pose)); const host = fakeHost(); - return { viewer, poses, host, loads, renderers, stepped }; + return { + viewer, + poses, + host, + loads, + renderers, + stepped, + asked, + attempts, + }; } const parts = (host) => { @@ -245,27 +273,37 @@ test('without WebGL, or when the panorama fails, a 360° photo shows its front v }); test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => { - const { viewer, poses, host, stepped } = setup({ + const { viewer, poses, host, stepped, asked } = setup({ steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' }, }); await viewer.mount(host); await viewer.open('flat1'); const { root, prev, next } = parts(host); - await settle(); - assert.equal(prev.disabled, true, 'flat1 is the first photo: known up front'); + assert.ok( + await until(() => prev.disabled), + 'flat1 is the first photo: known up front', + ); assert.equal(next.disabled, false); next.fire('click'); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(poses.at(-1).imageId, 'flat2'); + assert.ok(await until(() => poses.at(-1).imageId === 'flat2')); assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it'); - assert.equal(next.disabled, true, 'flat2 is the last photo'); + assert.ok(await until(() => next.disabled), 'flat2 is the last photo'); assert.equal(prev.disabled, false); + // A modified arrow, then a plain → (at the end: it only asks). Presses run + // in turn, so once → has asked, a stepping ⌘← would have asked first. + const before = asked.length; root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); + root.fire('keydown', { key: 'ArrowRight' }); + assert.ok(await until(() => asked.length > before)); await settle(); - assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours'); + assert.deepEqual( + asked.slice(before), + ['flat2:1'], + 'modified arrows are not ours', + ); + assert.equal(poses.at(-1).imageId, 'flat2'); root.fire('keydown', { key: 'ArrowLeft' }); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(poses.at(-1).imageId, 'flat1'); + assert.ok(await until(() => poses.at(-1).imageId === 'flat1')); }); test('quick presses each step on from the photo the last one chose', async () => { @@ -279,7 +317,7 @@ test('quick presses each step on from the photo the last one chose', async () => const { next } = parts(host); next.fire('click'); next.fire('click'); - for (let i = 0; i < 8; i++) await settle(); + assert.ok(await until(() => stepped.length === 2), `stepped ${stepped}`); assert.deepEqual(stepped, ['flat2', 'pano1']); }); @@ -318,13 +356,16 @@ test('a panorama is put on screen before its image is sized into the canvas', as }); test('a step that gets no answer is not the end of the sequence', async () => { - const { viewer, host, poses } = setup({ failSteps: true }); + const { viewer, host, poses, asked } = setup({ failSteps: true }); await viewer.mount(host); await viewer.open('flat1'); const { next } = parts(host); next.disabled = false; + // The ends check asked once already; the press asks again and fails. + const asks = () => asked.filter((ask) => ask === 'flat1:1').length; next.fire('click'); - for (let i = 0; i < 4; i++) await settle(); + assert.ok(await until(() => asks() === 2), 'the press asked'); + await settle(); assert.equal(next.disabled, false, 'still enabled: try again'); assert.equal(poses.at(-1).imageId, 'flat1'); }); @@ -332,28 +373,36 @@ test('a step that gets no answer is not the end of the sequence', async () => { test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => { globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); t.after(() => delete globalThis.requestAnimationFrame); - const { viewer, host, renderers, stepped } = setup({ + const { viewer, host, renderers, stepped, asked } = setup({ steps: { 'pano1:1': 'flat1' }, }); await viewer.mount(host); await viewer.open('pano1'); const { root } = parts(host); const view = () => renderers[0].getView(); + const before = asked.length; root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); assert.ok(view().yaw > 0, 'turned right'); root.fire('keydown', { key: 'ArrowUp' }); assert.ok(view().pitch > 0, 'looked up'); - const before = view().fov; + const fov = view().fov; root.fire('keydown', { key: '+' }); - assert.ok(view().fov < before, 'zoomed in'); + assert.ok(view().fov < fov, 'zoomed in'); root.fire('keydown', { key: '-' }); root.fire('keydown', { key: '-' }); - assert.ok(view().fov > before, 'zoomed out'); - for (let i = 0; i < 4; i++) await settle(); - assert.deepEqual(stepped, [], 'none of those stepped'); + assert.ok(view().fov > fov, 'zoomed out'); root.fire('keydown', { key: 'ArrowRight' }); - for (let i = 0; i < 4; i++) await settle(); + assert.ok(await until(() => stepped.length === 1)); assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps'); + // Only that press asked, then flat1's ends check: a Shift+→ that stepped + // would have sent the plain → on from flat1. + assert.ok(await until(() => asked.slice(before).length >= 3)); + await settle(); + assert.deepEqual( + asked.slice(before).sort(), + ['flat1:-1', 'flat1:1', 'pano1:1'], + 'none of the others stepped', + ); }); test('two fingers pinch the panorama’s zoom', async () => { @@ -391,8 +440,7 @@ test('a lost WebGL context drops the panorama and shows the photo again on a new await viewer.open('pano1'); parts(host).canvas.fire('webglcontextlost'); assert.equal(renderers[0].destroyed, true); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(renderers.length, 2, 'a new renderer'); + assert.ok(await until(() => renderers.length === 2), 'a new renderer'); assert.equal(poses.at(-1).imageId, 'pano1'); assert.equal(parts(host).canvas.hidden, false); }); @@ -424,3 +472,263 @@ test('a WebGL failure only lasts until the viewer is mounted again', async () => assert.equal(built.length, 1); assert.equal(parts(again).canvas.hidden, false); }); + +/** Flat photos a → d of one drive, for the tests that hold loads back. */ +const DRIVE = Object.fromEntries( + ['a', 'b', 'c', 'd'].map((id, index) => [ + id, + { + id, + sequenceId: 'drive', + sequenceIndex: index, + lon: 0.1 + index * 0.0001, + lat: 0.1, + heading: 0, + isPano: false, + capturedAt: 0, + urls: { + view: `https://storage2.openstreetcam.org/lth/${id}.jpg`, + full: `https://storage2.openstreetcam.org/proc/${id}.jpg`, + pano: null, + }, + }, + ]), +); +const DRIVE_ORDER = Object.keys(DRIVE); + +/** Which drive photo, at which size, an image address shows. */ +function shows(src) { + const proxied = + /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:(\d+):\d+\/([\w-]+)$/.exec( + src || '', + ); + const url = proxied ? Buffer.from(proxied[2], 'base64url').toString() : src; + const id = /\/(\w+)\.jpg$/.exec(url || '')?.[1] ?? null; + return proxied ? `${id}@${proxied[1]}` : id; +} + +/** + * A viewer whose photo loads (and, with `slowSteps`, step answers) wait for + * the test. ‹ › open through a stand-in core that, like the real one, awaits + * a tick (the viewer mount) before it opens the photo. + */ +function heldSetup({ slowSteps = false } = {}) { + const loads = []; + const asks = []; + const stepped = []; + const shown = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!DRIVE[id]) throw new Error('no such photo'); + return DRIVE[id]; + }, + step: (photo, delta) => { + const answer = DRIVE_ORDER[DRIVE_ORDER.indexOf(photo.id) + delta] ?? null; + if (!slowSteps) return Promise.resolve(answer); + return new Promise((resolve) => + asks.push({ key: `${photo.id}:${delta}`, resolve }), + ); + }, + onStep: async (id) => { + stepped.push(id); + await settle(); + viewer.open(id).catch(() => {}); + }, + onShown: (photo) => shown.push(photo.id), + load: (url) => + new Promise((resolve, reject) => + loads.push({ + url, + shows: shows(url), + resolve: () => resolve({ src: url }), + reject: () => + reject(new Error('The KartaView photo could not be loaded')), + }), + ), + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose.imageId)); + const host = fakeHost(); + /** Let the load showing `what` (e.g. 'a@1280') finish. */ + const release = async (what) => { + assert.ok( + await until(() => loads.some((load) => load.shows === what)), + `${what} is loading: ${loads.map((load) => load.shows)}`, + ); + const at = loads.findIndex((load) => load.shows === what); + loads.splice(at, 1)[0].resolve(); + await settle(); + }; + /** Answer the oldest pending step ask for `key` (e.g. 'a:1'). */ + const answer = async (key, value) => { + assert.ok(await until(() => asks.some((ask) => ask.key === key))); + const at = asks.findIndex((ask) => ask.key === key); + asks.splice(at, 1)[0].resolve(value); + await settle(); + }; + return { viewer, host, loads, asks, stepped, shown, poses, release, answer }; +} + +test('quick presses while the next photo is still loading each step on from the last one chosen', async () => { + const { viewer, host, stepped, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + await release('a@1280'); + await opening; + const { next } = parts(host); + assert.ok(await until(() => !next.disabled)); + // b, c and d never load: each press steps on from the photo on its way. + next.fire('click'); + next.fire('click'); + next.fire('click'); + assert.ok(await until(() => stepped.length >= 3), `stepped ${stepped}`); + await settle(); + assert.deepEqual(stepped, ['b', 'c', 'd']); +}); + +test('a slow sharp version of an older photo never replaces the newer photo', async () => { + const { viewer, host, shown, poses, release } = heldSetup(); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('b'); + await release('b@1280'); + await second; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + await release('a@2560'); // a's upgrade, overtaken + assert.equal(shows(picture.src), 'b@1280', 'a never comes back'); + await release('b@2560'); + assert.equal(shows(picture.src), 'b@2560', 'b’s own upgrade lands'); + assert.deepEqual(shown, ['a', 'b']); + assert.deepEqual(poses, ['a', 'b']); +}); + +test('an overtaken flat open never shows, nor tells the provider it did', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const first = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + const second = viewer.open('b'); + await release('b@1280'); + await second; + await release('a@1280'); + await first; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + assert.deepEqual(poses, ['b']); + assert.deepEqual(shown, ['b'], 'onShown only for the photo on screen'); +}); + +test('a photo still loading when the viewer closes stays closed', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + viewer.close(); + await release('a@1280'); + await opening; + const { picture, root } = parts(host); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + assert.deepEqual(poses, []); + assert.deepEqual(shown, []); +}); + +test('an ends check for an earlier photo does not disable › on the photo now showing', async () => { + const { viewer, host, release, answer } = heldSetup({ slowSteps: true }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('c'); + await release('c@1280'); + await second; + const { prev, next } = parts(host); + await answer('c:-1', 'b'); + await answer('c:1', 'd'); + // a's check answers last, saying nothing lies before or after it. + await answer('a:-1', null); + await answer('a:1', null); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false, 'c still has a next photo'); +}); + +test('a press whose answer comes after another photo was opened is dropped', async () => { + const { viewer, host, stepped, release, answer, asks } = heldSetup({ + slowSteps: true, + }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + await answer('a:-1', null); + await answer('a:1', 'b'); + const { next } = parts(host); + next.disabled = false; + next.fire('click'); + assert.ok(await until(() => asks.some((ask) => ask.key === 'a:1'))); + // The user picks another photo meanwhile. + const other = viewer.open('d'); + await release('d@1280'); + await other; + await answer('a:1', 'b'); + await settle(); + assert.deepEqual(stepped, [], 'the old press does not open b over d'); +}); + +test('a failed open does not become the photo ‹ › step from', async () => { + const { viewer, host, stepped } = setup({ steps: { 'flat1:1': 'flat2' } }); + await viewer.mount(host); + await viewer.open('flat1'); + await assert.rejects(viewer.open('nope'), /no such photo/); + parts(host).next.disabled = false; + parts(host).next.fire('click'); + assert.ok(await until(() => stepped.length === 1)); + assert.deepEqual(stepped, ['flat2'], 'from the photo still showing'); +}); + +test('WebGL is tried once per mount, not again for every 360° photo', async () => { + const { viewer, host, attempts } = setup({ rendererThrows: true }); + await viewer.mount(host); + await viewer.open('pano1'); + await viewer.open('pano2'); + assert.equal(attempts.renderer, 1); + assert.equal(parts(host).picture.hidden, false, 'front views instead'); +}); + +test('a step button that disables under the keyboard hands focus to the viewer', async () => { + const { viewer, host } = setup(); + await viewer.mount(host); + const { root, prev, next } = parts(host); + let focused = 0; + root.focus = () => focused++; + next.ownerDocument = { activeElement: next }; + prev.ownerDocument = { activeElement: root }; + await viewer.open('flat1'); + assert.ok(await until(() => next.disabled && prev.disabled)); + assert.equal(focused, 1, 'only for the focused button'); +}); + +test('› to another 360° photo keeps the look direction; opening one afresh faces its heading', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, poses } = setup({ + steps: { 'pano1:1': 'pano2' }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + const { root, next } = parts(host); + root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); + const yaw = renderers[0].getView().yaw; + assert.ok(yaw > 0); + next.fire('click'); + assert.ok(await until(() => poses.at(-1).imageId === 'pano2')); + assert.equal(renderers[0].getView().yaw, yaw, 'still looking the same way'); + await viewer.open('pano1'); + assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead'); +}); diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index f7da6412e..070fe3d91 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -659,3 +659,17 @@ test('FIT/FILL stays hidden for a second Street View panorama after a close', as await host.open('google', 'g-2'); assert.equal(state.street.renderModes, false); }); + +test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => { + const adapter = fakeAdapter(); + let frameable = false; + adapter.setRenderMode = () => {}; + adapter.supportsRenderModes = () => frameable; + const { state, host } = harness(adapter); + await host.open('mapillary', 'pano-1'); + assert.equal(state.street.imageId, 'pano-1', 'the pose arrived'); + assert.equal(state.street.renderModes, false); + frameable = true; + await host.open('mapillary', 'flat-1'); + assert.equal(state.street.renderModes, true, 'a flat image can be framed'); +}); diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index 4bd601e21..142b2d39c 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -58,3 +58,16 @@ export function fakeHost() { host.ownerDocument = doc; return host; } + +/** + * Wait, a macrotask at a time, until `condition` holds; false if it never + * does within `ticks`. A test asserting that something did not happen waits + * on a sign that the work is done instead of a fixed count of ticks. + */ +export async function until(condition, { ticks = 200 } = {}) { + for (let i = 0; i < ticks; i++) { + if (await condition()) return true; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return Boolean(await condition()); +} diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index b3d6c3a13..73702c1f1 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,6 +1,11 @@ /** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */ import * as Cesium from 'cesium'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; +import { + latToTileY, + lonToTileX, + tileBounds, +} from '../layers/streetLevel/tileMath.js'; const RAD = Math.PI / 180; @@ -141,3 +146,92 @@ export function rayCamera({ computeViewRectangle: () => undefined, }; } + +/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ +export function tileCentre(lon, lat, z = 14) { + const x = lonToTileX(lon, z); + const y = latToTileY(lat, z); + const { west, east, south, north } = tileBounds(x, y, z); + return { + tile: { x, y, z }, + lon: (west + east) / 2, + lat: (south + north) / 2, + }; +} + +/** A scene collection that remembers what is on the globe. */ +function sceneCollection() { + const items = new Set(); + return { + items, + add(primitive) { + items.add(primitive); + return primitive; + }, + remove(primitive) { + return items.delete(primitive); + }, + }; +} + +/** + * A viewer looking straight down on a 0.004° square around `view` + * (`{lon, lat, height}`, mutable), enough for line coverage to attach, + * refresh and draw. Cesium's one-time terrain-height download is skipped. + */ +export function coverageViewer(view, t) { + const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights; + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () => + Promise.resolve(); + t?.after(() => { + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize; + }); + // Node has no WebGL context to report line-width limits. + Cesium.ContextLimits._minimumAliasedLineWidth = 1; + Cesium.ContextLimits._maximumAliasedLineWidth = 10; + const listeners = () => { + const set = new Set(); + return { + set, + addEventListener(listener) { + set.add(listener); + return () => set.delete(listener); + }, + }; + }; + return { + view, + scene: { + canvas: { clientWidth: 100, clientHeight: 100 }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + groundPrimitives: sceneCollection(), + primitives: sceneCollection(), + postRender: listeners(), + preRender: listeners(), + }, + camera: { + changed: new Cesium.Event(), + moveEnd: new Cesium.Event(), + get positionWC() { + return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); + }, + get positionCartographic() { + return { + longitude: view.lon * RAD, + latitude: view.lat * RAD, + height: view.height, + }; + }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + view.lon - 0.002 + (point.x / 100) * 0.004, + view.lat + 0.002 - (point.y / 100) * 0.004, + ); + }, + computeViewRectangle: () => undefined, + }, + }; +} diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 33ae4e4a0..11680c5d3 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -26,9 +26,13 @@ import { KV_LINE, KV_SEQUENCE_ID, kartaViewPhotos, + resetKartaViewFixture, } from '../../scripts/fixtures/street-level/kartaView.mjs'; import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; -import { normalizePhoto } from '../layers/streetLevel/providers/kartaview/policy.js'; +import { + normalizePhoto, + resizedImageUrl, +} from '../layers/streetLevel/providers/kartaview/policy.js'; // The press helpers wait for paint; Node has no frames, so a tick stands in. globalThis.requestAnimationFrame ??= (callback) => @@ -665,10 +669,16 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`, }); assert.deepEqual(body(empty).lines, []); - const near = answerKartaViewRequest({ - method: 'GET', - url: `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`, - }); + const nearUrl = `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`; + resetKartaViewFixture(); + // The first lookup is rate-limited, readable cross-origin; the retry answers. + const limited = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(limited.status, 429); + assert.equal(limited.known, true); + assert.equal(limited.headers['Retry-After'], '0'); + assert.match(limited.headers['Access-Control-Expose-Headers'], /Retry-After/); + const near = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(near.status, 200); const records = body(near).result.data; assert.ok(records.length > 1); assert.ok(Number(records[0].distance) <= Number(records[1].distance)); @@ -678,6 +688,42 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known, true, ); + // Every odd photo is archived: its file answers 409, an even one the image. + const photos = kartaViewPhotos(); + const storageAnswer = (index) => + answerKartaViewRequest({ + method: 'GET', + url: normalizePhoto( + body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${photos[index].id}`, + }), + ).result.data, + ).urls.view, + }); + assert.equal(photos[3].archived, true); + assert.equal(storageAnswer(3).status, 409); + assert.equal(storageAnswer(3).known, true); + assert.equal(photos[4].archived, false); + assert.equal(storageAnswer(4).status, 200); + assert.equal(storageAnswer(4).contentType, 'image/jpeg'); + // The image proxy still serves an archived photo: the 360° step opens one. + const pano = photos.find((p) => p.isPano); + assert.equal(pano.archived, true); + const panoRecord = body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${pano.id}`, + }), + ).result.data; + assert.equal( + answerKartaViewRequest({ + method: 'GET', + url: resizedImageUrl(normalizePhoto(panoRecord).urls.pano, [1024, 512]), + }).status, + 200, + ); assert.equal( answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known, false, From 0394a9b9080a6de3ae89ce3453f33be133e1ab80 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 20:48:41 +0100 Subject: [PATCH 24/78] test: README now counts eighteen layers with a keyless path (Street Level via KartaView) Co-Authored-By: Claude Opus 5.5 --- src/tooling/transitQa.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 2ec277748..51e4d5c44 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr ); assert.ok( readme.includes( - 'Twenty layers and map sources. **Seventeen have a keyless path.**', + 'Twenty layers and map sources. **Eighteen have a keyless path.**', ), ); assert.ok( From 97a1a667dee586f5be92f05f1716d1a9e232df27 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 21:03:13 +0100 Subject: [PATCH 25/78] =?UTF-8?q?fix(street-level):=20KartaView=20round-2?= =?UTF-8?q?=20follow-ups=20=E2=80=94=20context=20loss,=20size=20cap,=20slo?= =?UTF-8?q?w=20lookups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the second IC8 pass (launch ready; P3s closed here): - A lost WebGL context is redrawn on a new canvas (the old one only gives back its lost context), through the core, and only if no newer photo is opening; verified live with WEBGL_lose_context, twice. - The 16 MB answer cap counts bytes while reading: KartaView sends chunked answers with no Content-Length, so the header check never fired. - A lookup gets 8 s, then 15 s once more: KartaView sometimes sits on a lookup for 11 s and answers the same one in 3 s. Left as follow-ups: cancelling abandoned sequence pages; finding a page by binary search on the index when thousands of photos are missing. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 ++- .../streetLevel/providers/kartaview/policy.js | 5 ++ .../streetLevel/providers/kartaview/source.js | 48 ++++++++++++++--- .../providers/kartaview/source.test.mjs | 53 +++++++++++++++++-- .../streetLevel/providers/kartaview/viewer.js | 39 ++++++++++---- .../providers/kartaview/viewer.test.mjs | 21 +++++++- src/testSupport/kartaViewFakes.mjs | 8 +++ 7 files changed, 158 insertions(+), 24 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 449f1cf68..d51bf44a1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3221,7 +3221,10 @@ up front at the ends. `api.kartaview.org` speaks HTTP/1.1, so of the browser's six connections to it tiles take three, lookups two and pages one (newest first), and a lookup never waits behind tiles; the photo API answers 429 to more than about three requests at once, so a 429 is retried with -backoff (at most 10 s) and a timed-out lookup once. Photo URLs are built from +backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView +sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers +are read with a running byte count and cut off past 16 MB (KartaView sends +them chunked, without a length). Photo URLs are built from `storage` and `filepath*`: the API's `fileurl*` always name storage1, where photos stored elsewhere 404, and a `fileurl*` fallback must itself be an https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px, @@ -3232,7 +3235,8 @@ equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the canvas re-measures every frame). Without WebGL, or if the proxy fails within 15 s, a 360° photo shows its front view; a lost context draws the photo again -on a new one. FIT / FILL hide for panoramas (`supportsRenderModes`). The +on a new canvas (a lost canvas only gives back its lost context), through the +core and only if no newer photo is opening. FIT / FILL hide for panoramas (`supportsRenderModes`). The caption has no place name (KartaView's is where the drive began) and links to the full-size photo through the image proxy, since kartaview.org's photo pages fail for signed-out visitors. diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 9df86b887..96d27bc92 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -66,6 +66,11 @@ export const TILE_CONCURRENCY = 3; export const PAGE_CONCURRENCY = 1; export const TILE_TIMEOUT_MS = 20_000; export const PHOTO_API_TIMEOUT_MS = 15_000; +/** + * The first try of a lookup: KartaView sometimes sits on a lookup for 11 s + * and more, then answers the same one again in 3 s. + */ +export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000; /** A sequence page is about 650 kB of JSON. */ export const PAGE_TIMEOUT_MS = 30_000; /** Real tiles are under 1 MB; refuse anything absurd before parsing it. */ diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index aaf684aba..9e926a9b0 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -10,6 +10,7 @@ import { PHOTO_API_RETRIES, PHOTO_API_RETRY_BASE_MS, PHOTO_API_RETRY_MAX_MS, + PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, TILE_TIMEOUT_MS, @@ -144,11 +145,33 @@ export function createKartaViewSource({ { status: response.status }, ); const length = Number(response.headers?.get?.('content-length')); - if (length > MAX_RESPONSE_BYTES) - throw new KartaViewSourceError('KartaView sent too much data', { + const tooMuch = () => + new KartaViewSourceError('KartaView sent too much data', { status: response.status, }); - return await response.json(); + if (length > MAX_RESPONSE_BYTES) throw tooMuch(); + // KartaView answers chunked, without a length: count while reading. + const reader = response.body?.getReader?.(); + if (!reader) return await response.json(); + const chunks = []; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) { + reader.cancel().catch(() => {}); + throw tooMuch(); + } + chunks.push(value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); } catch (error) { if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; @@ -168,7 +191,11 @@ export function createKartaViewSource({ path, params, { signal } = {}, - { gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {}, + { + gate = limiter, + timeoutMs = PHOTO_API_TIMEOUT_MS, + firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS, + } = {}, ) { const query = params ? `?${new URLSearchParams(params)}` : ''; const url = `${apiHost}${path}${query}`; @@ -176,7 +203,12 @@ export function createKartaViewSource({ for (let attempt = 0; ; attempt++) { try { const payload = await gate.run( - () => getJson(url, { signal, timeoutMs }), + // A slow first answer is usually quick when asked again. + () => + getJson(url, { + signal, + timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs, + }), { signal }, ); const code = Number(payload?.status?.apiCode); @@ -300,7 +332,11 @@ export function createKartaViewSource({ `/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`, { page: String(page), itemsPerPage: String(itemsPerPage) }, { signal }, - { gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS }, + { + gate: pageLimiter, + timeoutMs: PAGE_TIMEOUT_MS, + firstTimeoutMs: PAGE_TIMEOUT_MS, + }, ); return { photos: Array.isArray(result.data) ? result.data : [], diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index bed8b2ec5..c0061c42f 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -10,6 +10,7 @@ import { NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES, + PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, } from './policy.js'; @@ -402,7 +403,7 @@ test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s'); }); -test('a lookup that hangs is given up after the photo timeout, once more, then reported', async (t) => { +test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const asked = []; const outcome = await onMockClock(t, (stamp) => @@ -415,9 +416,9 @@ test('a lookup that hangs is given up after the photo timeout, once more, then r }, }).getPhoto('1'), ); - assert.deepEqual(asked, [0, PHOTO_API_TIMEOUT_MS]); + assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]); assert.match(String(outcome?.error?.message), /did not answer in time/); - assert.equal(outcome.at, 2 * PHOTO_API_TIMEOUT_MS); + assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS); }); test('the nearest lookup asks for the few photos around the point at the search zoom', async () => { @@ -439,3 +440,49 @@ test('the nearest lookup asks for the few photos around the point at the search assert.equal(NEAREST_SEARCH_ZOOM, 18); assert.equal(NEAREST_LIMIT, 10); }); + +test('a chunked answer with no length is cut off once it grows past the cap', async () => { + let reads = 0; + let cancelled = false; + const chunk = new Uint8Array(6 * 1024 * 1024); + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + ++reads > 10 ? { done: true } : { done: false, value: chunk }, + cancel: async () => { + cancelled = true; + }, + }), + }, + }), + }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/); + assert.equal(reads, 3, 'stopped at the third 6 MB chunk'); + assert.equal(cancelled, true); + const small = createKartaViewSource({ + fetchImpl: async () => { + const bytes = new TextEncoder().encode('{"lines":[]}'); + let given = false; + return { + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => ({ + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: bytes }), + cancel: async () => {}, + }), + }, + }; + }, + }); + assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index e10787b72..fc6d5ef19 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -83,6 +83,8 @@ export function createKartaViewViewer({ let drag = null; let poseFrame = 0; let removeListeners = []; + /** The document the viewer was built in (new canvases come from it). */ + let documentOf = null; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -202,13 +204,35 @@ export function createKartaViewViewer({ } catch { /* already gone */ } - if (showing === 'pano' && photo) { + // A lost canvas hands back its lost context: draw on a new element. + const fresh = makeCanvas(documentOf); + fresh.hidden = canvas.hidden; + canvas.replaceWith?.(fresh); + canvas = fresh; + // Show the photo again through the core, unless a newer one is opening. + if (showing === 'pano' && photo && openingId === null) { const again = photo.id; show(null); - adapter.open(again).catch(() => {}); + keepViewFor = again; + openingId = again; + onStep(again); } } + /** The panorama canvas, wired for drag, pinch, wheel and context loss. */ + function makeCanvas(doc) { + const element = doc.createElement('canvas'); + element.className = 'sl-kv-pano'; + element.hidden = true; + listen(element, 'pointerdown', onPointerDown); + listen(element, 'pointermove', onPointerMove); + listen(element, 'pointerup', onPointerUp); + listen(element, 'pointercancel', onPointerUp); + listen(element, 'wheel', onWheel, { passive: false }); + listen(element, 'webglcontextlost', onContextLost); + return element; + } + /** * Step along the sequence from the photo showing, or from the one on its * way after an earlier press, and open it through the core. @@ -346,6 +370,7 @@ export function createKartaViewViewer({ function build(host) { const doc = host.ownerDocument; + documentOf = doc; root = doc.createElement('div'); root.className = 'sl-kv-viewer'; root.tabIndex = 0; @@ -359,9 +384,7 @@ export function createKartaViewViewer({ picture.alt = 'KartaView street-level photo'; picture.draggable = false; picture.hidden = true; - canvas = doc.createElement('canvas'); - canvas.className = 'sl-kv-pano'; - canvas.hidden = true; + canvas = makeCanvas(doc); const nav = (label, glyph, delta) => { const button = doc.createElement('button'); button.type = 'button'; @@ -380,12 +403,6 @@ export function createKartaViewViewer({ nextButton = nav('Next photo in this sequence (→)', '›', 1); root.append(picture, canvas, prevButton, nextButton); host.append(root); - listen(canvas, 'pointerdown', onPointerDown); - listen(canvas, 'pointermove', onPointerMove); - listen(canvas, 'pointerup', onPointerUp); - listen(canvas, 'pointercancel', onPointerUp); - listen(canvas, 'wheel', onWheel, { passive: false }); - listen(canvas, 'webglcontextlost', onContextLost); listen(root, 'keydown', onKeyDown); applyRenderMode(); } diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index fd3242ef0..d8a6bfa37 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -69,6 +69,7 @@ const PHOTOS = { function fakeRenderer(canvas) { const view = { yaw: 0, pitch: 0, fov: 80 }; return { + canvas, images: [], hiddenAtSetImage: [], setImage(image) { @@ -435,16 +436,32 @@ test('two fingers pinch the panorama’s zoom', async () => { }); test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => { - const { viewer, host, renderers, poses } = setup(); + const { viewer, host, renderers, poses, stepped } = setup(); await viewer.mount(host); await viewer.open('pano1'); - parts(host).canvas.fire('webglcontextlost'); + const lost = parts(host).canvas; + lost.fire('webglcontextlost'); assert.equal(renderers[0].destroyed, true); + // A lost canvas only ever gives back its lost context: a new element. + assert.notEqual(parts(host).canvas, lost); + assert.deepEqual(stepped, ['pano1'], 'reopened through the core'); assert.ok(await until(() => renderers.length === 2), 'a new renderer'); + assert.equal(renderers[1].canvas, parts(host).canvas); assert.equal(poses.at(-1).imageId, 'pano1'); assert.equal(parts(host).canvas.hidden, false); }); +test('a context lost while a newer photo opens leaves that open alone', async () => { + const { viewer, host, stepped } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + const opening = viewer.open('flat1'); + parts(host).canvas.fire('webglcontextlost'); + await opening; + assert.deepEqual(stepped, [], 'no reopen of the old panorama'); + assert.equal(parts(host).picture.hidden, false); +}); + test('a WebGL failure only lasts until the viewer is mounted again', async () => { let fail = true; const built = []; diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index 142b2d39c..a2e6452f6 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -30,6 +30,14 @@ export function fakeElement(tag) { if (list) list.splice(list.indexOf(element), 1); element.parentNode = null; }, + replaceWith(next) { + const parent = element.parentNode; + if (!parent) return; + parent.children[parent.children.indexOf(element)] = next; + next.parentNode = parent; + next.ownerDocument = element.ownerDocument; + element.parentNode = null; + }, setAttribute: (name, value) => (element.attributes[name] = value), removeAttribute: (name) => { delete element.attributes[name]; From c99eb931740e2132ed929670c8afd89bdc128461 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 00:36:58 +0100 Subject: [PATCH 26/78] =?UTF-8?q?fix(street-level):=20KartaView=20PR=20rev?= =?UTF-8?q?iew=20=E2=80=94=20context=20loss=20mid-load,=20chunked=20reads,?= =?UTF-8?q?=20source=20switches,=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the IC8 review of #1121 (2 P2, 7 P3; each with a test that fails on the old code): P2 - A WebGL context lost while a newer panorama loaded left a black viewer and a 'successful' open: the panorama now builds on the new canvas (or falls back to its front view). - The streaming body read every real answer takes was tested with one chunk only: answers split mid-character are reassembled; the shared test fake now streams, so every source test runs the production path. P3 - Lighting another source by its chip (params) now stops a running lookup and clears its spinner and error, as setProviderEnabled did; a restore that changes nothing leaves them alone. - Panoramas decode off the main thread (ImageBitmap, RGBA upload: ~90 ms → ~15 ms at 4,096 px on a phone-class CPU) and are 2,048 px docked, 4,096 once the viewer grows; a closed viewer keeps a 1×1 texture. - The lit KARTAVIEW chip label is #8f8aff (~6:1; #635bff was 3.7:1). - The viewer's keyboard focus ring is drawn above the photo. - Tests: a second context loss and a drag on the new canvas; the look direction kept across a loss; a page's 30 s first try. - The browser gate loses a real WebGL context in the 360° step and checks the 429 lookup was asked again (the fixture counter is reset first). Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 17 +- scripts/fixtures/street-level/kartaView.mjs | 5 + scripts/qa-street-level.mjs | 57 ++++++- src/layers/streetLevel/index.js | 25 ++- src/layers/streetLevel/index.test.mjs | 46 +++++ .../providers/kartaview/panorama.js | 25 ++- .../providers/kartaview/panorama.test.mjs | 27 +++ .../streetLevel/providers/kartaview/policy.js | 2 + .../providers/kartaview/source.test.mjs | 72 +++++++- .../streetLevel/providers/kartaview/viewer.js | 80 ++++++++- .../providers/kartaview/viewer.test.mjs | 159 +++++++++++++++++- src/ui/styles/street-level.css | 23 ++- 12 files changed, 512 insertions(+), 26 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d51bf44a1..f595ce519 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3231,12 +3231,17 @@ https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px, then 2,560 px, from the image proxy (`pr:sharp/rs:fit:W:H/`), with KartaView's own 1,280 px file as the fallback, and a 360° photo's equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to -1,024 then 4,096 px on `panorama.js`, a single-triangle WebGL renderer (image -centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the -canvas re-measures every frame). Without WebGL, or if the proxy fails within -15 s, a 360° photo shows its front view; a lost context draws the photo again -on a new canvas (a lost canvas only gives back its lost context), through the -core and only if no newer photo is opening. FIT / FILL hide for panoramas (`supportsRenderModes`). The +1,024 px, then 2,048 (docked) or 4,096 px (expanded, or once the viewer +grows), decoded off the main thread into an ImageBitmap and uploaded as RGBA +(about 90 ms → 15 ms for 4,096 px on a phone-class CPU), on `panorama.js`, a +single-triangle WebGL renderer (image centre = the camera heading; drag, +pinch, wheel, Shift+← →, ↑ ↓, + −; the canvas re-measures every frame; a +closed viewer keeps a 1×1 texture). Without WebGL, or if the proxy fails +within 15 s, a 360° photo shows its front view. A lost context draws the +photo again on a new canvas (a lost canvas only gives back its lost +context), through the core and facing where the user was looking; a newer +photo that was opening is drawn on the new canvas instead. FIT / FILL hide +for panoramas (`supportsRenderModes`). The caption has no place name (KartaView's is where the drive began) and links to the full-size photo through the image proxy, since kartaview.org's photo pages fail for signed-out visitors. diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 559c6a465..06d48b020 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -33,6 +33,11 @@ export function resetKartaViewFixture() { nearestLookups = 0; } +/** Nearest lookups answered since the last reset (the first got a 429). */ +export function kartaViewLookups() { + return nearestLookups; +} + /** The drive's photos, south to north; every tenth is 360°, every odd one archived. */ export function kartaViewPhotos() { const step = KV_SPACING_M / METRES_PER_DEG_LAT; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 7c8a92b30..153d7514c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -42,7 +42,9 @@ import { describeKartaViewCall, KV_LINE, KV_SEQUENCE_ID, + kartaViewLookups, kartaViewPhotos, + resetKartaViewFixture, } from './fixtures/street-level/kartaView.mjs'; const PANEL_ID = 'street-level-panel'; @@ -751,6 +753,8 @@ async function main() { * draped map so a line is where it is drawn; leaves Mapillary lit. */ async function kartaViewSteps() { + // The first lookup from here on is the rate-limited one. + resetKartaViewFixture(); const ui = () => page.evaluate(() => window.__godsEyeView.dataManager.layers @@ -895,6 +899,11 @@ async function main() { const street = (await ui()).street; if (!fixtures && /No street-level imagery/.test(street.error || '')) return skip('no KartaView photo near the line'); + if (fixtures) + assert.ok( + kartaViewLookups() >= 2, + `the rate-limited lookup was asked again (${kartaViewLookups()} lookups)`, + ); assert.equal(street.error, null, `viewer error: ${street.error}`); assert.equal(street.providerId, 'kartaview'); assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); @@ -964,7 +973,7 @@ async function main() { ); if (fixtures) await step( - 'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it', + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', async () => { const pano = photos.find((photo) => photo.isPano); await page.evaluate((id) => { @@ -1004,12 +1013,56 @@ async function main() { await uiUntil((v, was) => v.street.bearing !== was, bearing), 'dragging the panorama turned its bearing', ); + // A GPU reset: the panorama comes back on a new canvas, and drags. + assert.ok( + await page.evaluate(() => { + const canvas = document.querySelector('.sl-kv-pano'); + window.__kvLostCanvas = canvas; + const lose = canvas + .getContext('webgl') + ?.getExtension('WEBGL_lose_context'); + lose?.loseContext(); + return Boolean(lose); + }), + 'the browser can lose a WebGL context on purpose', + ); + assert.ok( + await settle( + page, + () => { + const canvas = document.querySelector('.sl-kv-pano'); + return ( + canvas && + canvas !== window.__kvLostCanvas && + !canvas.hidden && + canvas.width > 1 + ); + }, + null, + { timeout: 30_000 }, + ), + 'the panorama is drawn again on a new canvas', + ); + const after = await ui(); + assert.equal(after.street.imageId, pano.id); + assert.equal(after.street.isPano, true); + assert.equal(after.street.error, null); + const turned = after.street.bearing; + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) + await page.mouse.move(box.x + i * 15, box.y); + await page.mouse.up(); + assert.ok( + await uiUntil((v, was) => v.street.bearing !== was, turned), + 'the new canvas drags', + ); }, ); else await skipStep( 'fixtures only', - 'KartaView: a 360° photo opens on the panorama without FIT / FILL, and dragging turns it', + 'KartaView: a 360° photo opens on the panorama without FIT / FILL, dragging turns it, and it survives a lost WebGL context', ); await page.evaluate(() => window.__godsEyeView.dataManager.layers diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 6384b9217..54076cc90 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -342,17 +342,36 @@ export function createStreetLevelLayer({ function setProviderEnabled(providerId, on) { const entry = state.providers.get(providerId); if (!entry) return false; + const before = litIds(); const next = on !== false; if (next) for (const other of state.providers.values()) if (other !== entry && other.on) switchProvider(other, false); switchProvider(entry, next); - // A failed lookup's message was about the source just switched away. - if (!state.street.open) state.street.error = null; + sourceChanged(before); notify(); return true; } + const litIds = () => + [...state.providers] + .filter(([, entry]) => entry.on) + .map(([id]) => id) + .join(); + + /** + * After a chip, voice or a link changed the lit source: a lookup still + * running and its spinner or error were about the source switched away + * (an open photo keeps its own state). + */ + function sourceChanged(before) { + if (litIds() === before) return; + abortNearest(); + if (state.street.open) return; + state.street.error = null; + state.street.loading = false; + } + function switchProvider(entry, on) { if (entry.on === on) return; entry.on = on; @@ -618,9 +637,11 @@ export function createStreetLevelLayer({ filter: state.filter, }); const lit = litByParams(decoded.providers); + const before = litIds(); for (const [id, entry] of state.providers) if (id !== lit) switchProvider(entry, false); if (lit) switchProvider(state.providers.get(lit), true); + sourceChanged(before); notify(); setCoverageFilter(decoded.filter); return true; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index adbeaf0bc..f50bcd380 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -897,3 +897,49 @@ test('KartaView through the core: its own swatch without Selected, its lines cou false, ); }); + +test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => { + const slow = slowLookups(); + const looking = fakeProvider({ nearestImage: slow.nearestImage }); + const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); + const { layer } = await enabledLayer(t, [looking, other]); + layer.attachViewerHost({}); + const opening = layer.openNearest({ lat: 1, lon: 1 }); + await settle(); + assert.equal(layer.getUIState().street.loading, true); + layer.setParams({ mapillary: false, other: true }); + assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped'); + assert.equal( + layer.getUIState().street.loading, + false, + 'no spinner left behind', + ); + // The stopped lookup answers late: nothing opens, nothing is reported. + slow.lookups[0].answer.resolve('img1'); + assert.equal(await opening, false); + assert.deepEqual(looking.calls.open, []); + assert.equal(layer.getUIState().street.error, null); +}); + +test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => { + const failing = fakeProvider({ + nearestImage: async () => { + throw new Error('KartaView did not answer in time — try again'); + }, + }); + const { layer: second } = await enabledLayer(t, [ + failing, + fakeProvider({ id: 'other', pickPrefix: 'oth:' }), + ]); + second.attachViewerHost({}); + await second.openNearest({ lat: 1, lon: 1 }); + assert.match(second.getUIState().street.error, /did not answer/); + second.setParams({ mapillary: false, other: true }); + assert.equal(second.getUIState().street.error, null); + // Reapplying the same switches (a restore) leaves state alone. + await second.openNearest({ lat: 1, lon: 1 }); + const kept = second.getUIState().street.error; + assert.match(kept, /No street-level imagery/); + second.setParams({ mapillary: false, other: true }); + assert.equal(second.getUIState().street.error, kept); +}); diff --git a/src/layers/streetLevel/providers/kartaview/panorama.js b/src/layers/streetLevel/providers/kartaview/panorama.js index de9a1b603..50f348a9b 100644 --- a/src/layers/streetLevel/providers/kartaview/panorama.js +++ b/src/layers/streetLevel/providers/kartaview/panorama.js @@ -179,7 +179,17 @@ export function createPanoramaRenderer(canvas) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); + // RGBA: the format a decoded ImageBitmap uploads without conversion. + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + image, + ); + // The pixels are on the GPU now; a bitmap's copy can go. + image.close?.(); // Linear only: a mipmapped lookup draws a seam where longitude wraps. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); @@ -205,6 +215,19 @@ export function createPanoramaRenderer(canvas) { hasImage: () => loaded, clear() { loaded = false; + // Hand the 32 MB texture back: a closed viewer keeps only 1×1. + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array(4), + ); scheduleRender(); }, destroy() { diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs index 8bb9c9d62..5bb6380d1 100644 --- a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -140,3 +140,30 @@ test('the canvas is measured again when it draws, not only when its image arrive runFrames(); assert.deepEqual([canvas.width, canvas.height], [400, 250]); }); + +test('an image uploads as RGBA and its bitmap is released; closing hands the texture back as 1×1', (t) => { + const { gl, renderer } = stubPanorama(t); + const uploads = []; + gl.texImage2D = (...args) => uploads.push(args); + let closed = false; + renderer.setImage({ + width: 2048, + height: 1024, + close: () => (closed = true), + }); + assert.equal(uploads.length, 1); + assert.equal( + uploads[0][2], + 'RGBA', + 'the format a decoded bitmap uploads without conversion', + ); + assert.equal(closed, true, 'the decoded copy is freed once on the GPU'); + renderer.clear(); + const last = uploads.at(-1); + assert.deepEqual( + [last[3], last[4]], + [1, 1], + 'a closed viewer keeps a 1×1 texture', + ); + assert.equal(renderer.hasImage(), false); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 96d27bc92..200033449 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -100,6 +100,8 @@ export const SEQUENCE_PAGE_EDGE = 12; /** Equirectangular panorama sizes: a quick preview, then the sharp one. */ export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); +/** Enough for a docked viewer; an expanded one gets the full size. */ +export const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); /** A flat photo's first version, then its sharp one. */ export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index c0061c42f..cd96bb5e4 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -12,14 +12,37 @@ import { PHOTO_API_RETRIES, PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS, + PAGE_TIMEOUT_MS, TILE_CONCURRENCY, } from './policy.js'; +/** A body streamed as browsers do, in `size`-byte chunks. */ +function streamed(text, size = 16_384) { + const bytes = new TextEncoder().encode(text); + let at = 0; + return { + getReader: () => ({ + read: async () => { + if (at >= bytes.length) return { done: true }; + const value = bytes.slice(at, at + size); + at += size; + return { done: false, value }; + }, + cancel: async () => {}, + }), + }; +} + +// A 200 as browsers deliver it: streamed, so the code under test reads it +// the way it does in production (`json` is there only to fail loudly). const ok = (body) => ({ ok: true, status: 200, headers: new Map(), - json: async () => body, + body: streamed(JSON.stringify(body)), + json: async () => { + throw new Error('a browser answer is read from its stream'); + }, }); const status = (code, headers = new Map()) => ({ ok: false, @@ -358,7 +381,7 @@ const flush = () => new Promise((resolve) => setImmediate(resolve)); * Run `call` on the mocked clock in 100 ms steps until it settles (60 s at * most). `stamp()` reads the clock, for a fetch to record when it was asked. */ -async function onMockClock(t, call) { +async function onMockClock(t, call, { until = 60_000 } = {}) { let now = 0; const stamp = () => now; let outcome = null; @@ -367,7 +390,7 @@ async function onMockClock(t, call) { (value) => (outcome = { value, at: now }), (error) => (outcome = { error, at: now }), ); - while (!outcome && now < 60_000) { + while (!outcome && now < until) { await flush(); if (outcome) break; t.mock.timers.tick(100); @@ -486,3 +509,46 @@ test('a chunked answer with no length is cut off once it grows past the cap', as }); assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); }); + +test('a chunked answer split mid-character is reassembled in order', async () => { + const payload = { + lines: [ + { + track: [{ lat: '1', lng: '2' }], + name: 'Strada Memorandumului — Cluj-Napoca 😀', + }, + ], + note: 'ăîşţ'.repeat(200), + }; + const source = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + // 3-byte chunks cut multi-byte characters in two. + body: streamed(JSON.stringify(payload), 3), + }), + }); + assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), payload); +}); + +test('a sequence page gets the full 30 s on its first try, then 30 s once more', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const asked = []; + const outcome = await onMockClock( + t, + (stamp) => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + asked.push(stamp()); + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getSequencePhotos('9', { page: 1, itemsPerPage: 250 }), + { until: 120_000 }, + ); + assert.deepEqual(asked, [0, PAGE_TIMEOUT_MS]); + assert.match(String(outcome?.error?.message), /did not answer in time/); + assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index fc6d5ef19..ae04b0f5a 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -9,6 +9,7 @@ import { IMAGE_TIMEOUT_MS, KARTAVIEW_PROVIDER_ID, PANO_FULL_SIZE, + PANO_MID_SIZE, PANO_PREVIEW_SIZE, kartaViewPhotoUrl, resizedImageUrl, @@ -16,11 +17,23 @@ import { const LOAD_FAILED = 'The KartaView photo could not be loaded'; -/** Load an image (CORS-clean, for WebGL) within the timeout. */ +/** + * Load an image within the timeout. A CORS-clean one (for WebGL) is decoded + * off the main thread into an ImageBitmap where the browser can: uploading + * a decoded bitmap costs a fraction of uploading an (90 ms → 15 ms for + * 4096×2048 on a phone-class CPU). + */ export function loadImage( url, { crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, ) { + if ( + crossOrigin && + url && + typeof globalThis.createImageBitmap === 'function' && + typeof globalThis.fetch === 'function' + ) + return loadBitmap(url, timeoutMs); return new Promise((resolve, reject) => { if (!url) return reject(new Error(LOAD_FAILED)); const image = new Image(); @@ -42,6 +55,29 @@ export function loadImage( }); } +async function loadBitmap(url, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + mode: 'cors', + signal: controller.signal, + }); + if (!response.ok) throw new Error(LOAD_FAILED); + return await createImageBitmap(await response.blob()); + } catch { + throw new Error(LOAD_FAILED); + } finally { + clearTimeout(timer); + } +} + +/** The panorama size a canvas this wide can show sharply at this field of view. */ +export function panoSizeFor(canvasPixels, fovDeg) { + const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); + return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE; +} + /** * Viewer adapter for KartaView photos: a flat photo in an , a 360° * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step @@ -85,6 +121,10 @@ export function createKartaViewViewer({ let removeListeners = []; /** The document the viewer was built in (new canvases come from it). */ let documentOf = null; + /** A context loss's look direction, for the reopen of that photo. */ + let restoreView = null; + /** Width of the panorama texture on screen (0: the preview). */ + let panoWidth = 0; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -199,6 +239,9 @@ export function createKartaViewViewer({ event.preventDefault?.(); const lost = renderer; renderer = null; + // The new renderer starts facing the photo's heading: keep the user's look. + if (showing === 'pano' && photo && lost) + restoreView = { id: photo.id, view: lost.getView() }; try { lost?.destroy(); } catch { @@ -213,7 +256,6 @@ export function createKartaViewViewer({ if (showing === 'pano' && photo && openingId === null) { const again = photo.id; show(null); - keepViewFor = again; openingId = again; onStep(again); } @@ -427,22 +469,34 @@ export function createKartaViewViewer({ async function upgrade(target, seq, kind) { // A 360° photo shown flat (no WebGL) keeps its front view. if (kind === 'flat' && target.isPano) return; + const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE; + if (kind === 'pano' && size[0] <= panoWidth) return; const url = kind === 'pano' - ? resizedImageUrl(target.urls.pano, PANO_FULL_SIZE) - : resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); + ? resizedImageUrl(target.urls.pano, size) + : resizedImageUrl(target.urls.full, size); if (!url) return; try { const image = await load(url, { crossOrigin: kind === 'pano' }); if (seq !== openSeq || photo !== target || showing !== kind) return; - if (kind === 'pano') renderer.setImage(image); - else picture.src = image.src; + if (kind === 'pano') { + if (!renderer || size[0] <= panoWidth) return; + renderer.setImage(image); + panoWidth = size[0]; + } else picture.src = image.src; requestRender(); } catch { /* the first version stays */ } } + /** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */ + function panoSizeNow() { + const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = (canvas?.clientWidth || 0) * ratio; + return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG); + } + async function showPano(target, seq, keepView) { // Only the proxy can shrink a 13,000 px panorama: if it fails, the // caller shows the front view rather than waiting on it twice. @@ -450,13 +504,20 @@ export function createKartaViewViewer({ resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), { crossOrigin: true }, ); - if (seq !== openSeq || !renderer) return false; - if (!keepView || showing !== 'pano') + if (seq !== openSeq) return false; + // The context may have been lost while this loaded: build on the new + // canvas, or let the caller show the front view. + if (!ensureRenderer()) throw new Error(LOAD_FAILED); + const restore = restoreView?.id === target.id ? restoreView.view : null; + restoreView = null; + if (restore) renderer.setView(restore); + else if (!keepView || showing !== 'pano') renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG }); photo = target; // Shown first: a hidden canvas measures 0 and would be sized 1×1. show('pano'); renderer.setImage(image); + panoWidth = 0; return true; } @@ -549,6 +610,9 @@ export function createKartaViewViewer({ resize() { renderer?.resize(); + // Expanded: a docked viewer's 2,048 px panorama is now too soft. + if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) + upgrade(photo, openSeq, 'pano'); }, /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index d8a6bfa37..603a02512 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createKartaViewViewer } from './viewer.js'; +import { createKartaViewViewer, loadImage, panoSizeFor } from './viewer.js'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; const PHOTOS = { @@ -89,6 +89,17 @@ function fakeRenderer(canvas) { }; } +PHOTOS.pano2 = { + ...PHOTOS.pano1, + id: 'pano2', + sequenceIndex: 8, + urls: { + view: 'https://storage13.openstreetcam.org/lth/p2.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', + }, +}; + function setup({ failLoads = [], rendererThrows = false, @@ -228,7 +239,8 @@ test('a 360° photo shows on the panorama, preview first; dragging turns the bea assert.equal(poses.at(-1).bearing, 150); assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); await settle(); - assert.match(renderers[0].images.at(-1), /rs:fit:4096:2048/); + // Docked (400 px at 80°), 2,048 px is all it can show. + assert.match(renderers[0].images.at(-1), /rs:fit:2048:1024/); // Drag left by a quarter of the width: the view turns right. const frames = []; globalThis.requestAnimationFrame = (callback) => { @@ -749,3 +761,146 @@ test('› to another 360° photo keeps the look direction; opening one afresh fa await viewer.open('pano1'); assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead'); }); + +/** A viewer whose loads for `held` URLs wait until `release()`. */ +function heldPanoSetup(held) { + const renderers = []; + const stepped = []; + let release; + const gate = new Promise((resolve) => (release = resolve)); + const viewer = createKartaViewViewer({ + getPhoto: async (id) => PHOTOS[id], + step: async () => null, + onStep: (id) => { + stepped.push(id); + viewer.open(id).catch(() => {}); + }, + load: async (url) => { + if (held.some((part) => url.includes(part))) await gate; + return { src: url }; + }, + createRenderer: (canvas) => { + const renderer = fakeRenderer(canvas); + renderers.push(renderer); + return renderer; + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + return { viewer, renderers, stepped, poses, host: fakeHost(), release }; +} + +const pano2Preview = Buffer.from( + 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg', +).toString('base64url'); + +test('a context lost while a newer panorama loads shows that one on a new canvas, not a black viewer', async () => { + const { viewer, renderers, poses, host, release, stepped } = heldPanoSetup([ + pano2Preview, + ]); + await viewer.mount(host); + await viewer.open('pano1'); + const opening = viewer.open('pano2'); + await new Promise((resolve) => setTimeout(resolve, 0)); + parts(host).canvas.fire('webglcontextlost'); + release(); + await opening; + assert.equal( + poses.at(-1).imageId, + 'pano2', + 'the newer panorama is on screen', + ); + assert.equal(poses.at(-1).isPano, true); + assert.deepEqual(stepped, [], 'its own open, not a reopen of the old one'); + assert.equal(renderers.length, 2, 'drawn by a new renderer'); + assert.equal(renderers[1].canvas, parts(host).canvas); + assert.equal(parts(host).canvas.hidden, false); +}); + +test('a second context loss, on the new canvas, reopens again; the new canvas still drags', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, stepped, poses } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 2)); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 3), 'reopened twice'); + assert.deepEqual(stepped, ['pano1', 'pano1']); + assert.equal(renderers[2].canvas, parts(host).canvas); + const canvas = parts(host).canvas; + const before = poses.at(-1).bearing; + canvas.fire('pointerdown', { + button: 0, + pointerId: 7, + pointerType: 'mouse', + clientX: 200, + clientY: 100, + }); + canvas.fire('pointermove', { pointerId: 7, clientX: 100, clientY: 100 }); + canvas.fire('pointerup', { pointerId: 7 }); + assert.ok(await until(() => poses.at(-1).bearing !== before), 'dragged'); +}); + +test('a context loss keeps where the user was looking', async () => { + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + renderers[0].setView({ yaw: 70, pitch: 12, fov: 60 }); + parts(host).canvas.fire('webglcontextlost'); + assert.ok(await until(() => renderers.length === 2)); + assert.deepEqual(renderers[1].getView(), { yaw: 70, pitch: 12, fov: 60 }); +}); + +test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once expanded', async () => { + assert.deepEqual(panoSizeFor(400, 80), [2048, 1024]); + assert.deepEqual(panoSizeFor(1400, 80), [4096, 2048]); + const { viewer, host, renderers } = setup(); + await viewer.mount(host); + await viewer.open('pano1'); + assert.ok(await until(() => renderers[0].images.length === 2)); + assert.match(renderers[0].images[1], /rs:fit:2048:1024/); + // Expanded: the panel tells the viewer it resized. + parts(host).canvas.clientWidth = 1400; + viewer.resize(); + assert.ok(await until(() => renderers[0].images.length === 3)); + assert.match(renderers[0].images[2], /rs:fit:4096:2048/); + viewer.resize(); + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); + assert.equal(renderers[0].images.length, 3, 'not again once sharp'); +}); + +test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => { + const saved = { + fetch: globalThis.fetch, + bitmap: globalThis.createImageBitmap, + }; + t.after(() => { + globalThis.fetch = saved.fetch; + globalThis.createImageBitmap = saved.bitmap; + }); + const fetched = []; + globalThis.fetch = async (url, init) => { + fetched.push({ url, mode: init.mode }); + return url.includes('gone') + ? { ok: false, status: 409 } + : { ok: true, blob: async () => 'blob' }; + }; + globalThis.createImageBitmap = async (blob) => ({ + blob, + width: 4096, + height: 2048, + }); + const bitmap = await loadImage('https://cdn.kartaview.org/a', { + crossOrigin: true, + }); + assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 }); + assert.deepEqual(fetched, [ + { url: 'https://cdn.kartaview.org/a', mode: 'cors' }, + ]); + await assert.rejects( + loadImage('https://cdn.kartaview.org/gone', { crossOrigin: true }), + /could not be loaded/, + ); +}); diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 302129b85..0e7258978 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -4,6 +4,9 @@ #street-level-panel, .sl-viewer-wrap { --sl-green: #05cb63; + /* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */ + --sl-kartaview: #635bff; + --sl-kartaview-text: #8f8aff; --sl-text: 0.62rem; --sl-text-small: 0.6rem; } @@ -174,6 +177,11 @@ button.sl-status:focus-visible { ); } +/* #635bff text on its own tint is 3.7:1 at this size; the lighter one is ~6:1. */ +.sl-chips .data-toggle-chip.active[data-chip-id='kartaview'] { + color: var(--sl-kartaview-text); +} + .sl-chips .data-toggle-chip.active::before { opacity: 1; } @@ -623,7 +631,18 @@ button.sl-status:focus-visible { } .sl-kv-viewer:focus-visible { - box-shadow: inset 0 0 0 2px var(--sl-kartaview, #635bff); + outline: none; +} + +/* The photo and canvas fill the viewer and would cover a ring drawn on it: + draw the ring on a layer above them (clicks pass through). */ +.sl-kv-viewer:focus-visible::after { + content: ''; + position: absolute; + inset: 0; + z-index: 3; + border: 2px solid var(--sl-kartaview-text); + pointer-events: none; } .sl-kv-photo, @@ -679,7 +698,7 @@ button.sl-status:focus-visible { .sl-kv-step:hover:not(:disabled), .sl-kv-step:focus-visible { - background: rgba(99, 91, 255, 0.85); + background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent); outline: none; } From 128a689a5699efcc4ceaf0fd24868368b2b4eced Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 11:44:25 +0100 Subject: [PATCH 27/78] fix(cctv): make the NYC DOT pack opt-in and carry DOT's disclaimer NYC DOT asks developers who use its camera feed to sign a data-sharing agreement, and this project does not hold one that covers downstream installs. The pack now loads only when CCTV_NYC_ENABLED=1; unset, empty or any other value makes no request to webcams.nyctmc.org (tested). The NYC credit carries NYC DOT's requested application disclaimer verbatim, and DATA_SOURCES, README, CHANGELOG, CURRENT-STATE and .env.example describe the prerequisite. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- CHANGELOG.md | 10 +++--- DATA_SOURCES.md | 4 +-- README.md | 2 +- docs/CURRENT-STATE.md | 15 ++++---- server/providers/cctv/catalog.js | 9 +++-- src/data/cctvNyc.test.mjs | 62 +++++++++++++++++--------------- src/data/dataCredits.js | 12 +++++-- src/data/dataCredits.test.mjs | 10 +++++- 9 files changed, 79 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 0b9a0037a..be746e3e4 100644 --- a/.env.example +++ b/.env.example @@ -215,7 +215,7 @@ AISSTREAM_API_KEY= # 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_URL= # override the Statens vegvesen catalog URL -# CCTV_NYC_ENABLED=1 # set 0 to disable the NYC DOT Traffic Management Center cameras +# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement (tmcdot@dot.nyc.gov) for the camera feed # CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) # CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b12ee132..8053a997e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog - Add New York City traffic cameras from the NYC DOT Traffic Management Center - as a CCTV pack. The camera list is one keyless JSON request to + as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the + camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an + operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's + requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each camera UUID on that same host, so a catalog field cannot steer the proxy - off-host. Offline cameras are dropped. By default the 250 cameras nearest - the five borough cores load. `CCTV_NYC_ENABLED=0` turns the pack off and - `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest + the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. - Treat explicitly current, complete empty vessel coverage as a successful update, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index dbb07f0bf..0e2595237 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,7 +56,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" | -| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | Public NYC DOT traffic camera stills | "NYC DOT Traffic Management Center" (courtesy) | +| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **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) | @@ -92,7 +92,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. -- **NYC DOT Traffic Management Center cameras.** Keyless. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap and `CCTV_NYC_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. +- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim 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/README.md b/README.md index 38f7a4913..70c4edf28 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **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** | ~4,100 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) · New York City (NYC DOT TMC). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). 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 | | 📻 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 38bfe3a20..7711e1e9e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -916,17 +916,20 @@ tuning; a delayed Enable result cannot reveal or refocus removed controls. ## NYC DOT TMC CCTV source pack -The CCTV catalog adds New York City traffic cameras from the NYC DOT Traffic -Management Center alongside the other live packs. The server reads the keyless +The CCTV catalog can add New York City traffic cameras from the NYC DOT +Traffic Management Center. NYC DOT requires developers to sign a data-sharing +agreement for the camera feed, so this is the one opt-in pack: it loads only +when `CCTV_NYC_ENABLED=1`, and with the flag unset the server makes no NYC +request. NYC DOT's requested disclaimer is in the Data attribution popover. +When enabled, the server reads the keyless camera list at `https://webcams.nyctmc.org/api/cameras` and keeps cameras the feed marks online. Frame URLs are built from each camera UUID under `https://webcams.nyctmc.org/api/cameras//image`; they are never taken from the payload. There is no heading in the feed, so gizmos use the shared id-hash fallback at low confidence. -By default the 250 cameras nearest the five borough cores load. -`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400) and `CCTV_NYC_ENABLED=0` -turns the pack off. The catalog-wide ceiling stays at 4,000 and is shared +Once enabled, the 250 cameras nearest the five borough cores load. +`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared round-robin, so adding this pack does not silently drop another region. ## DriveBC CCTV source pack @@ -3073,7 +3076,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) + NYC DOT TMC + 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) + NYC DOT TMC (opt-in) + 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/catalog.js b/server/providers/cctv/catalog.js index 0939a71da..99e627310 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -24,13 +24,16 @@ import { /** Env kill switch: unset or anything but "0" means enabled. */ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; +/** Env opt-in: only "1" enables. For packs whose feed access needs an + * operator agreement, so an unset flag never reaches the upstream. */ +const envOptIn = (name) => String(process.env[name] || '').trim() === '1'; /** * Live open-data packs, in merge order. Adding a region is one entry here * plus its loader in sources.js; the catalog cap is shared across entries * round-robin (cap.js), so a new pack never silently evicts an older one. * Each pack fails independently (allSettled) and is gated by its own env - * kill switch. + * kill switch, or by an opt-in flag where access needs an agreement. */ const LIVE_PACKS = [ { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, @@ -100,8 +103,10 @@ const LIVE_PACKS = [ load: loadVegvesenSourcesFromOpenData, }, { + // NYC DOT requires a data-sharing agreement for this feed, so the pack is + // off unless the operator opts in (see DATA_SOURCES.md). name: 'nyc', - enabled: () => envEnabled('CCTV_NYC_ENABLED'), + enabled: () => envOptIn('CCTV_NYC_ENABLED'), load: loadNycSourcesFromOpenData, }, ]; diff --git a/src/data/cctvNyc.test.mjs b/src/data/cctvNyc.test.mjs index d67ffa002..e96755f5a 100644 --- a/src/data/cctvNyc.test.mjs +++ b/src/data/cctvNyc.test.mjs @@ -321,53 +321,59 @@ const runCatalogWithMockedUpstreams = async (t) => { return { requested, sources }; }; -test('the NYC lane is wired into the catalog and its loader runs', async (t) => { +/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined + * leaves it unset), restoring the environment afterwards. */ +const runCatalogWithNycFlag = async (t, flag) => { const saved = { ...process.env }; try { delete process.env.CCTV_SOURCES_FILE; delete process.env.CCTV_SOURCES_JSON; - delete process.env.CCTV_NYC_ENABLED; - const { requested, sources } = await runCatalogWithMockedUpstreams(t); - assert.ok( - requested.includes(NYC_CAMERAS_URL), - 'the catalog refresh invokes the NYC loader', - ); - assert.deepEqual( - sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), - [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], - 'NYC cameras reach the served catalog through the registered lane', - ); + if (flag === undefined) delete process.env.CCTV_NYC_ENABLED; + else process.env.CCTV_NYC_ENABLED = flag; + return await runCatalogWithMockedUpstreams(t); } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; } Object.assign(process.env, saved); } +}; + +test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => { + const { requested, sources } = await runCatalogWithNycFlag(t, '1'); + assert.ok( + requested.includes(NYC_CAMERAS_URL), + 'the catalog refresh invokes the NYC loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + 'NYC cameras reach the served catalog through the registered lane', + ); }); -test('CCTV_NYC_ENABLED=0 keeps the lane from being loaded at all', async (t) => { - const saved = { ...process.env }; - try { - delete process.env.CCTV_SOURCES_FILE; - delete process.env.CCTV_SOURCES_JSON; - process.env.CCTV_NYC_ENABLED = '0'; - const { requested, sources } = await runCatalogWithMockedUpstreams(t); +// NYC DOT requires a data-sharing agreement for this feed, so an install that +// has not opted in must never contact it. +for (const [label, flag] of [ + ['an unset CCTV_NYC_ENABLED', undefined], + ['an empty CCTV_NYC_ENABLED', ''], + ['CCTV_NYC_ENABLED=0', '0'], + ['CCTV_NYC_ENABLED=true', 'true'], +]) { + test(`${label} makes no NYC request`, async (t) => { + const { requested, sources } = await runCatalogWithNycFlag(t, flag); + assert.ok(requested.length > 0, 'the other packs still refreshed'); assert.equal( - requested.includes(NYC_CAMERAS_URL), + requested.some((href) => href.includes('nyctmc.org')), false, - 'the disabled lane never reaches its upstream', + 'the lane never reaches its upstream', ); assert.deepEqual( sources.filter((s) => s.cityId === 'nyc'), [], ); - } finally { - for (const key of Object.keys(process.env)) { - if (!(key in saved)) delete process.env[key]; - } - Object.assign(process.env, saved); - } -}); + }); +} test('the shipped catalog ceiling is not raised to make room for this pack', () => { assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 891cd21e2..e68caddb4 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) { return true; } +/** NYC DOT asks every application that uses its data to carry this + * disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */ +export const NYC_DOT_DISCLAIMER = + 'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' + + 'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' + + 'The public is advised to observe posted signage for compliance with applicable laws and regulations.'; + export const DATA_CREDITS = [ // ── Live sources ──────────────────────────────────────────────── { @@ -250,8 +257,9 @@ export const DATA_CREDITS = [ { key: 'nyc-cctv', html: - 'CCTV cameras & frames (New York City): ' + - 'NYC DOT Traffic Management Center', + 'CCTV cameras & frames (New York City, when enabled): ' + + 'NYC DOT Traffic Management Center. ' + + NYC_DOT_DISCLAIMER, }, { key: 'gbfs', diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 46de48972..30a19b8fe 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DATA_CREDITS } from './dataCredits.js'; +import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js'; test('every credit carries a unique key and some markup to render', () => { const keys = DATA_CREDITS.map((entry) => entry.key); @@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner assert.equal(a.credits.size, 0); assert.equal(b.credits.size, 1); }); + +test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv'); + assert.ok(credit, 'NYC DOT camera frames must be credited'); + assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER)); + assert.match(credit.html, /make no representation as to the accuracy/); + assert.match(credit.html, /observe posted signage/); +}); From e9a4dbb0625dec71d3c0ae209e124a09079bf810 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 12:17:33 +0100 Subject: [PATCH 28/78] fix(street-level): KartaView marker and camera direction; remaining review P4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker and direction (reported on the PR): - KartaView has its own marker, a purple view cone (Street View's is blue), not GEV's default pin that Mapillary uses. - The camera direction (marker, cones, FOLLOW, a panorama's centre) is the direction of travel to the next photo a few metres on. KartaView's heading is the device compass: whole drives read 0 (no compass) and others sit a steady ~110° off. Until the drive loads the compass stands in (0 = unknown), and the pose is sent again once the course is known. Remaining P4s from the review of #1121: - Segment chaining is linear (a per-point cursor) and capped at 20,000 segments a tile and 5,000 points a line: a hostile tile of 60k segments sharing one point took 1.5 s, now tens of ms. - Decoded tiles are dropped when KartaView is switched off. - No network / a refused request says so and is not retried as a timeout; an unreadable answer says so. - A docked flat photo skips the 2,560 px download until the viewer grows. - The panel counts the drive's photos (countActivePhotos), not the thinned page of cones (2,250, not 217). - After a GPU reset the viewer redraws the photo itself, so a lookup the user started is not cancelled; a failure is still reported. - The gate's keyless page lights KartaView and checks it draws with no key; the fixture answers the sequence count; the line-click step checks the camera looks along the drive when the compass says 200°. - README/CHANGELOG show the share-link form the app writes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- README.md | 2 +- docs/CURRENT-STATE.md | 24 +++- scripts/fixtures/street-level/kartaView.mjs | 16 ++- scripts/qa-street-level.mjs | 51 +++++++- .../streetLevel/providers/kartaview/decode.js | 24 +++- .../providers/kartaview/decode.test.mjs | 43 ++++++- .../streetLevel/providers/kartaview/index.js | 77 +++++++++--- .../streetLevel/providers/kartaview/policy.js | 53 +++++++- .../providers/kartaview/policy.test.mjs | 45 ++++++- .../providers/kartaview/provider.test.mjs | 119 +++++++++++++++++- .../streetLevel/providers/kartaview/source.js | 56 +++++++-- .../providers/kartaview/source.test.mjs | 79 +++++++++--- .../streetLevel/providers/kartaview/viewer.js | 36 +++++- .../providers/kartaview/viewer.test.mjs | 19 +++ 15 files changed, 588 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0df3bc64..8a3261ba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,12 @@ look, wheel to zoom), with cones along its drive and ‹ › / ← → to step through it. The caption links to the full-size photo (KartaView's photo pages fail for signed-out visitors), and the globe credits "© KartaView - contributors, CC BY-SA 4.0". Share links carry it as `0.m.0_0.r.kartaview-1`. Archived photos (KartaView answers 409) are skipped by the click and by ‹ ›; a keyboard and pinch look around a 360° photo. + contributors, CC BY-SA 4.0". Share links carry it in the provider + switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView + answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and + pinch look around a 360° photo. The open photo's marker is a purple view + cone pointing along the drive (KartaView's own compass headings are often + 0 or far off). Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index b58a2eaec..e55c94a77 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -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.m.0_0.r.google-1` Street View on, `0.m.0_0.r.kartaview-1` KartaView on (a link that leaves out `m` keeps Mapillary, the default, lit), `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`. +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.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `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 f595ce519..21b894fb3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3195,14 +3195,16 @@ ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not answer), so higher up the hint says to zoom in, and an empty view says there are no KartaView photos in it. A tile's lines are OSM way segments with photos and carry no sequence, date or projection, so `decode.js` chains -segments that meet end to start (about four times fewer lines), they are +segments that meet end to start (about four times fewer lines; linear time, +at most 20,000 segments a tile and 5,000 points a line), they are drawn as `kv:line:`, up to 1,600 per tile, and never highlighted (`highlightsSequence: false`, so the legend has no "Selected"), and the provider declares `filters: []` and `coverageLabel` ("N streets in view · click a line for its nearest photo"). Tiles come uncompressed and -uncacheable, so the last 24 decoded tiles are kept for 10 minutes -(`createTileCache`); one that does not answer in 20 s is asked once more, -and one that is not a line list is not cached. A click on a line reaches +uncacheable, so the last 24 decoded tiles are kept for 10 minutes while +KartaView is lit (`createTileCache`, dropped when it is switched off); one +that does not answer in 20 s is asked once more, and one that is not a line +list is not cached. A click on a line reaches `handlePick(id, {groundPoint})`, and the provider asks the core (`actions.openNearest`) for its nearest showable photo within 50 m, without framing: many photos are archived (their file answers 409, while a HEAD says @@ -3211,7 +3213,15 @@ off at the headers, and where all are gone the panel says KartaView's photos there are archived. Only a definite answer (403/404/409/410, or 200) is remembered; a storage timeout or 5xx is asked again. The open photo's sequence draws as cones from the 250-photo page holding it -(`/2.0/sequence/{id}/photos`). Pages follow photo ids, which only roughly +(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos +(`countActivePhotos`, asked once per sequence). The camera direction (the +marker, cones, FOLLOW, and a panorama's centre) is the direction of travel +to the next photo a few metres on (`assignCourses`): KartaView is dashcam +imagery, while its `heading` is the device compass, which reads 0 for whole +drives without one and can sit a steady 110° off inside a car. Until the +drive's page arrives the compass stands in (0 counts as unknown), and the +pose is sent again once the course is known. The open photo's marker is a +purple view cone (`marker: {kind: 'view-cone'}`), as Street View's is blue. Pages follow photo ids, which only roughly follow the index, so the page is found by search (both ways, halving past the end when many photos were deleted), a neighbour page joins within 12 photos of an edge, and the cones re-centre whenever a photo of the selected sequence @@ -3222,7 +3232,9 @@ browser's six connections to it tiles take three, lookups two and pages one (newest first), and a lookup never waits behind tiles; the photo API answers 429 to more than about three requests at once, so a 429 is retried with backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView -sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers +sometimes sits on a lookup for 11 s and answers the same one in 3 s); a +request that could not be made at all (no network, CORS) says so and is not +repeated. Answers are read with a running byte count and cut off past 16 MB (KartaView sends them chunked, without a length). Photo URLs are built from `storage` and `filepath*`: the API's `fileurl*` always name storage1, where diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 06d48b020..1c85b0ec0 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -66,7 +66,8 @@ function record(photo) { lng: String(photo.lon), matchLat: String(photo.lat), matchLng: String(photo.lon), - heading: '0', + // A dashcam phone's compass, disturbed by the car: the drive runs north. + heading: '200', fieldOfView: photo.isPano ? '360' : '0', projection: photo.isPano ? 'SPHERE' : 'PLANE', shotDate: SHOT, @@ -117,6 +118,7 @@ export function describeKartaViewCall(method, url) { .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') .replace(/\/photo\/\d+$/, '/photo/{id}') .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') + .replace(/\/sequence\/\d+$/, '/sequence/{id}') .replace(/^\/files\/photo\/.*$/, '/files/photo/{path}') .replace( /^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/, @@ -202,6 +204,18 @@ export function answerKartaViewRequest({ method, url }) { ? { ...ok({ data: record(photo) }), known: true } : { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true }; } + const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/); + if (sequence) + return { + ...ok({ + data: { + id: sequence[1], + countActivePhotos: + sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0', + }, + }), + known: true, + }; const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/); if (page) { const q = address.searchParams; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 153d7514c..1355cb765 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -464,6 +464,45 @@ async function assertKeylessGate(page) { assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); } +/** + * With no Mapillary key the panel points at KartaView, which needs none: + * lit, it draws coverage and the layer is not key-gated. + */ +async function assertKeylessKartaView(page) { + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /light KARTAVIEW/, + 'KEY REQUIRED points at KartaView', + ); + for (let attempt = 0; attempt < 4; attempt++) { + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + }, PARK); + await sleep(1000); + } + await clickPanelControl( + page, + '#sl-provider-chips [data-chip-id="kartaview"]', + ); + await waitForCoverage(page); + const state = await page.evaluate(() => ({ + status: document.getElementById('sl-status').textContent, + keyRequired: window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().keyRequired, + })); + assert.equal(state.keyRequired, false); + assert.doesNotMatch(state.status, /KEY/); +} + /** * Runs in the page. Find a point where a real click picks `prefix` + `id` (any * id when null), searched around `points` ([lon, lat]), the aimed billboards, @@ -942,6 +981,15 @@ async function main() { ), `cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`, ); + // The drive runs north; the photos' compass says 200°. + assert.ok( + await uiUntil( + (u) => + Number.isFinite(u.street.bearing) && + Math.min(u.street.bearing, 360 - u.street.bearing) < 2, + ), + `the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`, + ); } const before = street.imageId; await clickPanelControl(page, '.sl-kv-step-next'); @@ -2444,7 +2492,7 @@ async function main() { ); if (fixtures) await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', + 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there', async () => { const keyless = await browser.newPage(); await keyless.setViewport(VIEWPORTS[0]); @@ -2453,6 +2501,7 @@ async function main() { await boot(keyless, url); await expandStrip(keyless); await assertKeylessGate(keyless); + await assertKeylessKartaView(keyless); renderErrorsSeen.push( ...(await readRenderErrors(keyless)).map( (text) => `[keyless] scene.renderError: ${text}`, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index d670f4ca2..b5488af00 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -1,3 +1,8 @@ +/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */ +export const MAX_TILE_SEGMENTS = 20_000; +/** A chained line stops growing here and the next one starts. */ +export const MAX_LINE_POINTS = 5_000; + /** * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into * line records for the shared coverage. The tile's lines are OSM way @@ -14,6 +19,7 @@ export function decodeCoverageTile(payload, { x, y, z }) { throw new Error('KartaView sent an unreadable coverage tile'); const segments = []; for (const line of payload.lines) { + if (segments.length >= MAX_TILE_SEGMENTS) break; const points = []; for (const point of Array.isArray(line?.track) ? line.track : []) { const lon = Number(point?.lng); @@ -34,16 +40,28 @@ export function decodeCoverageTile(payload, { x, y, z }) { else startingAt.set(at, [index]); }); const used = new Uint8Array(segments.length); + // Per start point, how far its list has been used: each segment is looked + // at once, so a tile of segments sharing one point stays linear. + const cursor = new Map(); + const nextFrom = (point) => { + const at = key(point); + const list = startingAt.get(at); + if (!list) return undefined; + let i = cursor.get(at) ?? 0; + while (i < list.length && used[list[i]]) i++; + cursor.set(at, i); + return list[i]; + }; const sequences = []; segments.forEach((segment, index) => { if (used[index]) return; used[index] = 1; const chain = [...segment]; for (;;) { - const next = startingAt - .get(key(chain[chain.length - 1])) - ?.find((candidate) => !used[candidate]); + const next = nextFrom(chain[chain.length - 1]); if (next === undefined) break; + // Full: the segment starts the next line instead. + if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break; used[next] = 1; chain.push(...segments[next].slice(1)); } diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index c84855e06..e6cc498d6 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { decodeCoverageTile } from './decode.js'; +import { + MAX_LINE_POINTS, + MAX_TILE_SEGMENTS, + decodeCoverageTile, +} from './decode.js'; const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); const TILE = { x: 4412, y: 6061, z: 14 }; @@ -85,3 +89,40 @@ test('a tile that is not a line list fails, so it is asked for again rather than /unreadable coverage tile/, ); }); + +test('a tile whose segments all share one point decodes in linear time', () => { + // [P, Q_i, P]: every chain step looks at the same start point's list. + // 60k: the old quadratic chaining took over a second; capped and linear + // decoding takes tens of milliseconds. + const lines = Array.from({ length: 60_000 }, (_, i) => ({ + track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)], + })); + const started = performance.now(); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const ms = performance.now() - started; + assert.ok( + ms < 1_000, + `${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.equal( + points, + MAX_TILE_SEGMENTS * 2 + sequences.length, + 'every segment drawn once', + ); +}); + +test('a tile is capped in segments and in points per line', () => { + const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({ + track: [at(i, 0), at(i + 1, 0)], + })); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + // One long chain, cut into lines of at most MAX_LINE_POINTS. + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length); + assert.ok( + sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS), + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 07e9672cf..0435b28d8 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -24,6 +24,8 @@ import { SEQUENCE_PAGE_EDGE, SEQUENCE_PAGE_SIZE, TOO_HIGH_HINT, + assignCourses, + cameraBearing, normalizePhoto, } from './policy.js'; @@ -86,6 +88,8 @@ export function createTileCache({ kept.set(key, { at: now(), tile: loaded }); return loaded; }, + /** Drop every tile (the provider was switched off). */ + clear: () => kept.clear(), }; } @@ -112,6 +116,8 @@ export function createKartaViewProvider({ requiresKeyId: null, pickPrefix: PICK_PREFIX.root, colors: COLORS, + // Its own marker, as Street View has: a purple cone that turns with the view. + marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), defaultOn: false, // Coverage lines carry no date or projection: no filter could thin them. @@ -162,9 +168,13 @@ export function createKartaViewProvider({ /** The sequence page the cones were drawn from. */ let drawnPage = null; + /** A record as a photo; the one already known keeps its identity (and course). */ function remember(record) { - const photo = normalizePhoto(record); - if (photo) photos.set(photo.id, photo); + const fresh = normalizePhoto(record); + if (!fresh) return null; + const known = photos.get(fresh.id); + const photo = known ? Object.assign(known, fresh) : fresh; + photos.set(photo.id, photo); return photo; } @@ -186,16 +196,20 @@ export function createKartaViewProvider({ page, itemsPerPage: SEQUENCE_PAGE_SIZE, }) - .then(({ photos: records, hasMore }) => ({ - page, - hasMore, - photos: records - .map(remember) - .filter(Boolean) - .sort( - (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), - ), - })); + .then(({ photos: records, hasMore }) => { + const list = assignCourses( + records + .map(remember) + .filter(Boolean) + .sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + metresBetween, + ); + // The open photo may have just learnt which way it looked. + viewer.refreshPose?.(); + return { page, hasMore, photos: list }; + }); pages.set(key, entry); entry.catch(() => pages.delete(key)); } @@ -353,12 +367,13 @@ export function createKartaViewProvider({ const window = await windowOf(anchor); signal?.throwIfAborted(); drawnPage = { sequenceId, page: window.home }; + countPhotos(sequenceId); return thinImages( window.photos.map((photo) => ({ id: photo.id, lon: photo.lon, lat: photo.lat, - compassAngle: photo.heading ?? 0, + compassAngle: cameraBearing(photo) ?? 0, capturedAt: photo.capturedAt ?? 0, isPano: photo.isPano, })), @@ -372,6 +387,23 @@ export function createKartaViewProvider({ passes: () => true, }); + /** Sequence id → its photo count from KartaView (asked once). */ + const counts = new Map(); + function countPhotos(sequenceId) { + if (counts.has(sequenceId) || !source.getSequenceCount) return; + counts.set(sequenceId, undefined); + source.getSequenceCount(sequenceId).then( + (count) => { + if (count === null) counts.delete(sequenceId); + else { + counts.set(sequenceId, count); + context.notify(); + } + }, + () => counts.delete(sequenceId), + ); + } + /** ‹ › open through the core (loading, errors, cones), camera still. */ function openStepped(photoId) { context.actions.openImage(photoId, { frame: false }); @@ -406,6 +438,17 @@ export function createKartaViewProvider({ step, onStep: openStepped, onShown: recentreCones, + // After a GPU reset the same photo is drawn again by the viewer + // itself: going through the core would cancel a lookup the user + // started. A failure still shows. + onReopen: (photoId) => + viewer + .open(photoId) + .catch((error) => + context.actions.reportError( + error?.message || 'The KartaView photo could not be loaded', + ), + ), render: context.services?.render, ...viewerOptions, }); @@ -434,6 +477,8 @@ export function createKartaViewProvider({ cones.clearSelection(); cones.setVisible(false); drawnPage = null; + // Tens of MB of decoded lines are not kept for a source left off. + tiles.clear(); }, destroy(cesiumViewer) { @@ -441,6 +486,7 @@ export function createKartaViewProvider({ coverage.clear(); coverage.resetErrors(); cones.destroy(cesiumViewer); + tiles.clear(); }, refreshCoverage: () => coverage.refresh(), @@ -495,9 +541,12 @@ export function createKartaViewProvider({ cones.clearSelection(); drawnPage = null; }, + // The drive's own count, not the thinned page of it the cones show. sequenceStats: () => ({ selectedId: state.sequence.selectedId, - images: state.sequence.images.length, + images: + counts.get(state.sequence.selectedId) ?? + state.sequence.images.length, loading: state.sequence.loading, }), diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 200033449..9930f2c5e 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -145,6 +145,56 @@ export function kartaViewTimeMs(value) { return Number.isFinite(ms) ? ms : null; } +/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */ +export function bearingBetween(a, b) { + const rad = Math.PI / 180; + const dLon = (b.lon - a.lon) * rad; + const y = Math.sin(dLon) * Math.cos(b.lat * rad); + const x = + Math.cos(a.lat * rad) * Math.sin(b.lat * rad) - + Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon); + return (Math.atan2(y, x) / rad + 360) % 360; +} + +/** Photos closer than this say nothing about the direction of travel. */ +const COURSE_MIN_STEP_M = 2; +/** How many photos along to look for one far enough away. */ +const COURSE_SEARCH = 5; + +/** + * Give each photo of a drive (in index order) its `course`: the direction + * of travel from it to the next photo a few metres on (from the previous one + * at the end). KartaView is dashcam imagery, so this is where the camera + * looks; its `heading` is the device compass, which reads 0 without one and + * can sit 110° off inside a car. + * @param {Array<{lon: number, lat: number, course?: number|null}>} photos + * @param {(a: object, b: object) => number} metres + */ +export function assignCourses(photos, metres) { + photos.forEach((photo, i) => { + let course = null; + for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++) + if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) { + course = bearingBetween(photo, photos[j]); + break; + } + for ( + let k = i - 1; + course === null && k >= i - COURSE_SEARCH && k >= 0; + k-- + ) + if (metres(photos[k], photo) >= COURSE_MIN_STEP_M) + course = bearingBetween(photos[k], photo); + photo.course = course; + }); + return photos; +} + +/** Where the camera looked: the direction of travel, else the compass, else unknown. */ +export function cameraBearing(photo) { + return photo?.course ?? photo?.heading ?? null; +} + /** A coordinate pair as numbers; the road-matched one when KartaView has it. */ function positionOf(record) { const matchLat = Number(record.matchLat); @@ -214,10 +264,11 @@ export function normalizePhoto(record) { // 0,0 is a missing fix, not a photo in the Gulf of Guinea. if (lat === 0 && lon === 0) return null; if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + // An exact 0 is a device without a compass (whole drives read 0), not north. const heading = record.heading === '' || record.heading == null ? NaN - : Number(record.heading); + : Number(record.heading) || NaN; const isPano = record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360; const full = diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index c9ab2ca4b..0698a9e8a 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + assignCourses, + bearingBetween, + cameraBearing, kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, @@ -140,7 +143,9 @@ test('records without an id or a position are dropped; a missing heading is null null, ); assert.equal(normalizePhoto(record({ heading: '' })).heading, null); - assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0); + // A device without a compass reads 0 for a whole drive: unknown, not north. + assert.equal(normalizePhoto(record({ heading: '0' })).heading, null); + assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5); assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null); }); @@ -237,3 +242,41 @@ test('with no processed-photo path the size prefix gives the full photo', () => /^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//, ); }); + +test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => { + // North along a meridian, 5 m apart, then a repeat of the last position. + const step = 5 / 110_540; + const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step })); + photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place + const metres = (a, b) => + Math.hypot( + (b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180), + (b.lat - a.lat) * 110_540, + ); + assignCourses(photos, metres); + for (const photo of photos) + assert.ok( + Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5, + `${photo.course}`, + ); + // Eastward: 90°. + const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 })); + assignCourses(east, metres); + assert.ok( + Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5, + ); + // A lone photo has no course; the compass is the fallback, then unknown. + const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres); + assert.equal(lone.course, null); + assert.equal(cameraBearing({ ...lone, heading: 42 }), 42); + assert.equal(cameraBearing({ ...lone, heading: null }), null); + assert.equal( + cameraBearing({ course: 90, heading: 200 }), + 90, + 'the drive beats the compass', + ); + assert.ok( + Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) < + 1e-9, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 9c63e9c18..055c3b1a6 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -714,7 +714,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); }); -test('a coverage tile loaded a moment ago is not asked for again', async (t) => { +test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => { const env = coverageSetup(t, { lines: [ [ @@ -725,11 +725,14 @@ test('a coverage tile loaded a moment ago is not asked for again', async (t) => }); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - // Off and on again (or a pan away and back): drawn from the kept tile. + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 1, 'kept while on'); + // Switched off: the decoded lines are not kept, so on again asks again. env.instance.deactivate(); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - assert.equal(env.tiles.length, 1); + assert.equal(env.tiles.length, 2); }); test('a photo no page lists steps to the photos either side of its index', async () => { @@ -746,3 +749,113 @@ test('a photo no page lists steps to the photos either side of its index', async await press(env.prev, env.poses); assert.equal(env.poses.at(-1), 's-4'); }); + +test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => { + const env = await mounted({ + sequences: { s: Array.from({ length: 40 }, (_, i) => i) }, + }); + const asked = []; + env.source.getSequenceCount = async (id) => { + asked.push(id); + return 2385; + }; + await withCones(t, env); + await env.instance.viewer.open('s-3'); + await env.instance.selectSequence('s'); + assert.ok( + await until(() => env.instance.sequenceStats().images === 2385), + `images: ${env.instance.sequenceStats().images}`, + ); + // Asked once per drive. + env.instance.clearSequence(); + await env.instance.viewer.open('s-30'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 2385)); + assert.deepEqual(asked, ['s']); +}); + +test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => { + const source = fakeSource(); + const record = { + ...photoRecord('pano', { seq: 'p', index: 0 }), + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: '2022/10/6/wrapped_proc/pano.jpg', + }; + source.getPhoto = async () => record; + const view = { yaw: 0, pitch: 0, fov: 80 }; + let failLoads = false; + const definition = createKartaViewProvider({ + source, + viewerOptions: { + load: async (url) => { + if (failLoads) + throw new Error('The KartaView photo could not be loaded'); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + await instance.viewer.open('pano'); + const canvas = () => host.children[0].children[1]; + const kind = () => host.children[0].dataset.kind; + canvas().fire('webglcontextlost'); + assert.equal(kind(), '', 'cleared while it is drawn again'); + assert.ok(await until(() => kind() === 'pano'), 'drawn again'); + assert.ok(poses.length >= 2); + assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone'); + failLoads = true; + canvas().fire('webglcontextlost'); + assert.ok(await until(() => errors.length === 1)); + assert.match(errors[0], /could not be loaded/); +}); + +test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => { + const env = setup({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + assert.deepEqual(env.definition.marker, { + kind: 'view-cone', + color: '#635bff', + }); + // Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°. + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => ({ + ...(await getPhoto(id)), + heading: '200', + }); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { + ...page, + photos: page.photos.map((record) => ({ ...record, heading: '200' })), + }; + }; + const host = fakeHost(); + await env.instance.viewer.mount(host); + const bearings = []; + env.instance.viewer.onPose((pose) => bearings.push(pose.bearing)); + await env.instance.viewer.open('s-4'); + assert.equal(bearings[0], 200, 'the compass until the drive is known'); + assert.ok( + await until(() => Math.abs(bearings.at(-1) - 90) < 1), + `then along the drive: ${bearings}`, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 9e926a9b0..4c1b47ff8 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -16,17 +16,27 @@ import { TILE_TIMEOUT_MS, } from './policy.js'; -/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */ +/** + * An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits, + * `timedOut` when it did not answer in time (the one failure worth asking + * again at once). + */ export class KartaViewSourceError extends Error { - constructor(message, { status = 0, retryAfterSec = null } = {}) { + constructor( + message, + { status = 0, retryAfterSec = null, timedOut = false } = {}, + ) { super(message); this.name = 'KartaViewSourceError'; this.status = status; this.retryAfterSec = retryAfterSec; + this.timedOut = timedOut; } } const NO_ANSWER = 'KartaView did not answer in time — try again'; +const UNREACHABLE = 'KartaView could not be reached — check the network'; +const UNREADABLE = 'KartaView sent an answer that could not be read'; /** Statuses that mean a photo file is gone, not that storage hiccupped. */ const GONE_STATUSES = Object.freeze([403, 404, 409, 410]); @@ -129,7 +139,10 @@ export function createKartaViewSource({ if (signal?.aborted) throw signal.reason; signal?.addEventListener('abort', onAbort, { once: true }); const timer = setTimeout( - () => controller.abort(new KartaViewSourceError(NO_ANSWER)), + () => + controller.abort( + new KartaViewSourceError(NO_ANSWER, { timedOut: true }), + ), timeoutMs, ); try { @@ -152,7 +165,16 @@ export function createKartaViewSource({ if (length > MAX_RESPONSE_BYTES) throw tooMuch(); // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); - if (!reader) return await response.json(); + if (!reader) { + try { + return await response.json(); + } catch (error) { + if (controller.signal.aborted) throw error; + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } + } const chunks = []; let bytes = 0; for (;;) { @@ -171,12 +193,19 @@ export function createKartaViewSource({ body.set(chunk, offset); offset += chunk.byteLength; } - return JSON.parse(new TextDecoder().decode(body)); + try { + return JSON.parse(new TextDecoder().decode(body)); + } catch { + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } } catch (error) { if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; if (error instanceof KartaViewSourceError) throw error; - throw new KartaViewSourceError(NO_ANSWER); + // fetch rejects on no network, a blocked request or a refused CORS check. + throw new KartaViewSourceError(UNREACHABLE); } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); @@ -221,7 +250,7 @@ export function createKartaViewSource({ return payload?.result ?? {}; } catch (error) { if (signal?.aborted) throw error; - const timedOut = error?.message === NO_ANSWER && error.status === 0; + const timedOut = error?.timedOut === true; if (timedOut && timeouts++ < 1) continue; if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) { if (error?.status === 429) @@ -259,7 +288,7 @@ export function createKartaViewSource({ } catch (error) { // A cold dense tile can outlast the timeout; KartaView has it ready // by the second ask. - if (signal?.aborted || error?.message !== NO_ANSWER) throw error; + if (signal?.aborted || error?.timedOut !== true) throw error; return once(); } }, @@ -322,6 +351,17 @@ export function createKartaViewSource({ return result.data; }, + /** How many photos a sequence has, or null if KartaView does not say. */ + async getSequenceCount(sequenceId, { signal } = {}) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}`, + null, + { signal }, + ); + const count = Number(result.data?.countActivePhotos); + return Number.isFinite(count) && count > 0 ? count : null; + }, + /** A page of a sequence's photos in capture order (1-based `page`). */ async getSequencePhotos( sequenceId, diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index cd96bb5e4..5ad3f6cb8 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -151,12 +151,19 @@ test('an API refusal inside a 200 is a readable error with its code; 601 is just test('HTTP errors, network failures and non-JSON become readable errors', async () => { const http = createKartaViewSource({ fetchImpl: async () => status(500) }); await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/); + let asked = 0; const offline = createKartaViewSource({ fetchImpl: async () => { + asked++; throw new TypeError('Failed to fetch'); }, }); - await assert.rejects(offline.getPhoto('1'), /did not answer/); + // Not a timeout: no network is said as much, and not asked again at once. + await assert.rejects( + offline.getPhoto('1'), + /could not be reached — check the network/, + ); + assert.equal(asked, 1); const garbled = createKartaViewSource({ fetchImpl: async () => ({ ok: true, @@ -169,7 +176,19 @@ test('HTTP errors, network failures and non-JSON become readable errors', async }); await assert.rejects( garbled.getTile({ x: 0, y: 0, z: 14 }), - /did not answer/, + /could not be read/, + ); + const torn = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: streamed('{"lines": [', 4), + }), + }); + await assert.rejects( + torn.getTile({ x: 0, y: 0, z: 14 }), + /could not be read/, ); }); @@ -265,18 +284,24 @@ test('tiles, lookups and sequence pages queue separately, so a lookup never wait assert.equal(peak.page, 1); }); -test('a lookup that times out is asked once more; a second timeout says to try again', async () => { +test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); let calls = 0; - const source = createKartaViewSource({ - fetchImpl: async () => { - calls++; - throw new TypeError('Failed to fetch'); - }, - }); - await assert.rejects( - source.getPhoto('1'), + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.match( + String(outcome?.error?.message), /did not answer in time — try again/, ); + assert.equal(outcome.error.timedOut, true); assert.equal(calls, 2); }); @@ -323,17 +348,35 @@ test('an image probe reads only the status: an archived photo (409) is not showa ); }); -test('a cold tile that times out is asked once more', async () => { +test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); let calls = 0; - const source = createKartaViewSource({ + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + if (calls === 1) + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + return Promise.resolve(ok({ lines: [] })); + }, + }).getTile({ x: 1, y: 2, z: 13 }), + ); + assert.deepEqual(outcome.value, { lines: [] }); + assert.equal(calls, 2); + let asked = 0; + const offline = createKartaViewSource({ fetchImpl: async () => { - calls++; - if (calls === 1) throw new TypeError('Failed to fetch'); - return ok({ lines: [] }); + asked++; + throw new TypeError('Failed to fetch'); }, }); - assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); - assert.equal(calls, 2); + await assert.rejects( + offline.getTile({ x: 1, y: 2, z: 13 }), + /could not be reached/, + ); + assert.equal(asked, 1); }); test('an absurdly large answer is refused before it is parsed', async () => { diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index ae04b0f5a..a9beb52c5 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -11,6 +11,7 @@ import { PANO_FULL_SIZE, PANO_MID_SIZE, PANO_PREVIEW_SIZE, + cameraBearing, kartaViewPhotoUrl, resizedImageUrl, } from './policy.js'; @@ -88,6 +89,7 @@ export function panoSizeFor(canvasPixels, fovDeg) { * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`). * @param {object} [options.render] Render governor. * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] @@ -98,6 +100,7 @@ export function createKartaViewViewer({ step, onStep, onShown = () => {}, + onReopen = onStep, render, load = loadImage, createRenderer = createPanoramaRenderer, @@ -125,6 +128,8 @@ export function createKartaViewViewer({ let restoreView = null; /** Width of the panorama texture on screen (0: the preview). */ let panoWidth = 0; + /** The flat photo on screen is the 2,560 px version. */ + let flatSharp = false; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -139,7 +144,7 @@ export function createKartaViewViewer({ function emit() { if (!photo || !showing) return; const view = showing === 'pano' ? renderer.getView() : null; - const heading = photo.heading; + const heading = cameraBearing(photo); const pose = { providerId: KARTAVIEW_PROVIDER_ID, imageId: photo.id, @@ -257,7 +262,7 @@ export function createKartaViewViewer({ const again = photo.id; show(null); openingId = again; - onStep(again); + onReopen(again); } } @@ -471,6 +476,10 @@ export function createKartaViewViewer({ if (kind === 'flat' && target.isPano) return; const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE; if (kind === 'pano' && size[0] <= panoWidth) return; + // A docked viewer shows the 1,280 px version in full: the 2,560 px one + // (~0.6 MB a photo) waits until the viewer is bigger than that. + if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0])) + return; const url = kind === 'pano' ? resizedImageUrl(target.urls.pano, size) @@ -483,13 +492,24 @@ export function createKartaViewViewer({ if (!renderer || size[0] <= panoWidth) return; renderer.setImage(image); panoWidth = size[0]; - } else picture.src = image.src; + } else { + picture.src = image.src; + flatSharp = true; + } requestRender(); } catch { /* the first version stays */ } } + /** Device pixels across the flat photo's frame. */ + function flatPixels() { + const ratio = globalThis.devicePixelRatio || 1; + return ( + Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio + ); + } + /** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */ function panoSizeNow() { const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); @@ -543,6 +563,7 @@ export function createKartaViewViewer({ if (!image) throw failure || new Error(LOAD_FAILED); if (seq !== openSeq) return false; picture.src = image.src; + flatSharp = false; photo = target; show('flat'); return true; @@ -610,9 +631,11 @@ export function createKartaViewViewer({ resize() { renderer?.resize(); - // Expanded: a docked viewer's 2,048 px panorama is now too soft. + // Expanded: a docked viewer's first version is now too soft. if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) upgrade(photo, openSeq, 'pano'); + if (showing === 'flat' && photo && !flatSharp) + upgrade(photo, openSeq, 'flat'); }, /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ @@ -623,6 +646,11 @@ export function createKartaViewViewer({ applyRenderMode(); }, + /** The photo's direction became known (its drive loaded): pose again. */ + refreshPose() { + emit(); + }, + onPose(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 603a02512..519e76615 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -168,6 +168,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa }); await viewer.mount(host); const { picture, canvas, prev, next } = parts(host); + picture.clientWidth = 1600; // expanded: the sharp version is wanted assert.equal(prev.disabled, true, 'nothing to step through yet'); await viewer.open('flat1'); assert.equal(picture.hidden, false); @@ -621,6 +622,8 @@ test('quick presses while the next photo is still loading each step on from the test('a slow sharp version of an older photo never replaces the newer photo', async () => { const { viewer, host, shown, poses, release } = heldSetup(); await viewer.mount(host); + // An expanded viewer, so the sharp version is wanted. + parts(host).picture.clientWidth = 1600; const first = viewer.open('a'); await release('a@1280'); await first; @@ -904,3 +907,19 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the /could not be loaded/, ); }); + +test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => { + const { viewer, host, loads } = setup(); + await viewer.mount(host); + await viewer.open('flat1'); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame'); + parts(host).picture.clientWidth = 1600; + viewer.resize(); + assert.ok(await until(() => loads.length === 2)); + assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(parts(host).picture.src, loads[1].url); + viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'once only'); +}); From 04b3231534c6b05cea50aa79b0a13c8f83f0e001 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 13:09:40 +0100 Subject: [PATCH 29/78] fix(street-level): KartaView review P2s; budget the gate's pick search - Viewer: resizes while a sharper photo loads start no second load (the last size is looked at once it lands); decoded bitmaps nobody will draw (an overtaken preview or upgrade) are closed. - Cones: a photo opened while the drive's first page loads is the one they centre on. - A sequence page that lands while a lost WebGL context recovers keeps its cones: no pose is emitted without a renderer, and a viewer error never rejects page data. - Source: an answer turned away unread (HTTP error, 429, length past the cap) aborts its request and cancels its body, freeing the connection. - Gate: findPickPoint no longer picks each centre five times at radius 0, aims step 13's cone pick at the drawn billboards, and caps each evaluation at 4 s, resuming from a cursor on the next poll, so a slow machine cannot spend a whole wait in one evaluation. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-street-level.mjs | 82 ++++++++++---- .../streetLevel/providers/kartaview/index.js | 20 +++- .../providers/kartaview/provider.test.mjs | 101 ++++++++++++++++++ .../streetLevel/providers/kartaview/source.js | 13 ++- .../providers/kartaview/source.test.mjs | 59 ++++++++++ .../streetLevel/providers/kartaview/viewer.js | 44 +++++++- .../providers/kartaview/viewer.test.mjs | 61 ++++++++++- 7 files changed, 350 insertions(+), 30 deletions(-) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1355cb765..4b60a0695 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -507,6 +507,7 @@ async function assertKeylessKartaView(page) { * Runs in the page. Find a point where a real click picks `prefix` + `id` (any * id when null), searched around `points` ([lon, lat]), the aimed billboards, * or else a grid mid-screen. Only points where the canvas is topmost count. + * Each call picks for at most `budgetMs`; the next call resumes the search. * @returns {{x: number, y: number, id: string}|null} client coordinates */ export function findPickPoint({ @@ -515,6 +516,7 @@ export function findPickPoint({ points, billboardIds, billboardPrefix = null, + budgetMs = 4000, }) { const viewer = window.__godsEyeView.viewer; const scene = viewer.scene; @@ -569,24 +571,54 @@ export function findPickPoint({ centres.push([at.x, at.y]); } } - for (const [cx, cy] of centres) - for (let r = 0; r <= 16; r += 2) + // A pick is a render of the scene (0.5–2 s each under SwiftShader): try + // the centres the canvas is on top of first, nearest the screen centre, + // so the first pick usually hits. + const uncovered = ([x, y]) => + document.elementFromPoint(rect.left + x, rect.top + y) === canvas; + const fromMiddle = ([x, y]) => + (x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2; + centres.sort( + (a, b) => + Number(uncovered(b)) - Number(uncovered(a)) || + fromMiddle(a) - fromMiddle(b), + ); + const candidates = []; + for (const [cx, cy] of centres) { + candidates.push([cx, cy]); + for (let r = 2; r <= 16; r += 2) for (const [dx, dy] of [ - [0, 0], [r, 0], [-r, 0], [0, r], [0, -r], - ]) { - const hit = tryAt(cx + dx, cy + dy); - if (hit) return hit; - } - if (points || billboardIds) return null; - for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) - for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { - const hit = tryAt(x, y); - if (hit) return hit; + ]) + candidates.push([cx + dx, cy + dy]); + } + // Aimed at known places: never fall back to a scan of the whole screen. + if (!points && !billboardIds && billboardPrefix === null) + for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) + for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) + candidates.push([x, y]); + // One evaluation stays well inside the caller's wait: past the budget it + // returns null, and the next poll carries on from where this one stopped. + const key = JSON.stringify([prefix, id, billboardPrefix]); + const cursors = (window.__qaPickCursors ??= {}); + const deadline = performance.now() + budgetMs; + let at = cursors[key] ?? 0; + if (at >= candidates.length) at = 0; + for (; at < candidates.length; at++) { + const hit = tryAt(...candidates[at]); + if (hit) { + delete cursors[key]; + return hit; } + if (performance.now() > deadline) { + cursors[key] = at + 1; + return null; + } + } + delete cursors[key]; return null; } @@ -1542,22 +1574,28 @@ async function main() { ); if (fixtures) assert.equal(sequence.images, fixture.images.length, 'every photo'); - // The cones are on the globe: one of them picks as an image. + // The cones are on the globe: one of them picks as an image. Aim at + // where each cone billboard is drawn (its ground position at + // height 0 can sit a few pixels off, and every missed pick costs a + // render); the same 30 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + { timeout: 30_000, polling: 250 }, { prefix: 'mly:img:', id: null, - points: fixtures - ? fixture.images.map((image) => [image.lon, image.lat]) - : null, + points: null, billboardIds: null, - // Live: the cones' positions are only known from the globe. - billboardPrefix: fixtures ? null : 'mly:img:', + billboardPrefix: 'mly:img:', }, ); - assert.ok(await cone.jsonValue(), 'an image cone is pickable'); + const picked = await cone.jsonValue(); + assert.ok(picked, 'an image cone is pickable'); + if (fixtures) + assert.ok( + fixture.images.some((image) => image.id === picked.id), + `the cone is a photo of the drive: ${picked.id}`, + ); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') @@ -1685,7 +1723,9 @@ async function main() { assert.ok(neighbour, 'a neighbour of the other camera type'); const handle = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + // A pick is a render; under SwiftShader on a busy machine one + // evaluation can take seconds. + { timeout: 30_000, polling: 250 }, { prefix: 'mly:img:', id: neighbour.id, diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 0435b28d8..da093112b 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -206,8 +206,13 @@ export function createKartaViewProvider({ ), metresBetween, ); - // The open photo may have just learnt which way it looked. - viewer.refreshPose?.(); + // The open photo may have just learnt which way it looked. The + // page is good whatever the viewer is doing. + try { + viewer.refreshPose?.(); + } catch { + /* the next pose carries the direction */ + } return { page, hasMore, photos: list }; }); pages.set(key, entry); @@ -362,10 +367,17 @@ export function createKartaViewProvider({ const cones = createSequenceCones({ state, async loadImages(sequenceId, { signal }) { - const anchor = anchors.get(sequenceId); + let anchor = anchors.get(sequenceId); if (!anchor) return []; - const window = await windowOf(anchor); + let window = await windowOf(anchor); signal?.throwIfAborted(); + // Another photo of the drive opened while that page loaded (its + // re-centre found nothing drawn yet): centre on the newest one. + while (anchors.get(sequenceId) !== anchor) { + anchor = anchors.get(sequenceId); + window = await windowOf(anchor); + signal?.throwIfAborted(); + } drawnPage = { sequenceId, page: window.home }; countPhotos(sequenceId); return thinImages( diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 055c3b1a6..ea82db277 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -859,3 +859,104 @@ test('the marker is KartaView’s own cone, and it points along the drive, not a `then along the drive: ${bearings}`, ); }); + +/** Hold `seq`'s page `page` until the returned function is called. */ +function holdPage(source, seq, page) { + const getPage = source.getSequencePhotos; + let release; + const gate = new Promise((resolve) => (release = resolve)); + source.getSequencePhotos = async (id, options) => { + if (id === seq && options.page === page) await gate; + return getPage(id, options); + }; + return () => release(); +} + +test('a photo opened while the cones first load is the one they centre on', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const release = holdPage(env.source, 's', 1); + await env.instance.viewer.open('s-10'); + const selected = env.instance.selectSequence('s'); + // A line click further along the drive while page 1 is still loading: + // nothing is drawn yet for its re-centre to move. + await env.instance.viewer.open(`s-${size * 4 + 10}`); + release(); + await selected; + assert.ok( + await until( + () => !env.instance.sequenceStats().loading && cones.count() > 0, + ), + ); + assert.ok( + cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10, + `around the newer photo, not the first: ${cones()}`, + ); +}); + +test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => { + const env = setup({ sequences: { p: [0, 1, 2, 3] } }); + const sphere = (record) => ({ + ...record, + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`, + }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => sphere(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(sphere) }; + }; + const release = holdPage(env.source, 'p', 1); + let holdImages = false; + let releaseImage = null; + const view = { yaw: 0, pitch: 0, fov: 80 }; + const definition = createKartaViewProvider({ + source: env.source, + viewerOptions: { + load: async (url) => { + if (holdImages) await new Promise((r) => (releaseImage = r)); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const cones = await withCones(t, { instance }); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open('p-0'); + // › to the next panorama; the GPU resets while it loads. + holdImages = true; + const opening = instance.viewer.open('p-1'); + assert.ok(await until(() => releaseImage !== null)); + host.children[0].children[1].fire('webglcontextlost'); + const selected = instance.selectSequence('p'); + release(); + await selected; + assert.ok( + await until(() => !instance.sequenceStats().loading && cones.count() > 0), + 'the page is drawn, not dropped', + ); + releaseImage(); + await opening; + assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows'); + // reportError(null) clears; nothing was reported. + assert.deepEqual(errors.filter(Boolean), []); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 4c1b47ff8..4283cf859 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -145,8 +145,10 @@ export function createKartaViewSource({ ), timeoutMs, ); + let response = null; + let read = false; try { - const response = await fetchImpl(url, { signal: controller.signal }); + response = await fetchImpl(url, { signal: controller.signal }); if (response.status === 429) throw new KartaViewSourceError('KartaView is rate-limiting requests', { status: 429, @@ -166,6 +168,7 @@ export function createKartaViewSource({ // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); if (!reader) { + read = true; try { return await response.json(); } catch (error) { @@ -175,6 +178,7 @@ export function createKartaViewSource({ }); } } + read = true; const chunks = []; let bytes = 0; for (;;) { @@ -209,6 +213,13 @@ export function createKartaViewSource({ } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + // An answer turned away unread (429, an error, too long) still holds + // its connection, one of the six the browser has: let it go. + if (response && !read) { + controller.abort(); + if (response.body && !response.body.locked) + response.body.cancel?.().catch?.(() => {}); + } } } diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 5ad3f6cb8..4aed435c7 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -595,3 +595,62 @@ test('a sequence page gets the full 30 s on its first try, then 30 s once more', assert.match(String(outcome?.error?.message), /did not answer in time/); assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS); }); + +test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => { + /** A response whose body is never read: records its cancel and the abort. */ + const unread = (answer) => { + const seen = { cancelled: false, signal: null }; + const fetchImpl = async (url, { signal }) => { + seen.signal = signal; + return { + ...answer, + body: { + locked: false, + getReader: () => { + throw new Error('not read'); + }, + cancel: async () => { + seen.cancelled = true; + }, + }, + }; + }; + return { seen, fetchImpl }; + }; + const cases = [ + [{ ok: false, status: 503, headers: new Map() }, /HTTP 503/], + [ + { + ok: true, + status: 200, + headers: new Map([['content-length', String(64 * 1024 * 1024)]]), + }, + /too much data/, + ], + ]; + for (const [answer, message] of cases) { + const { seen, fetchImpl } = unread(answer); + const source = createKartaViewSource({ fetchImpl }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message); + assert.equal(seen.signal.aborted, true, `${message}: request aborted`); + assert.equal(seen.cancelled, true, `${message}: body cancelled`); + } + // A 429 is let go before its retry is asked. + const { seen, fetchImpl } = unread({ + ok: false, + status: 429, + headers: new Map([['retry-after', '0']]), + }); + const signals = []; + const source = createKartaViewSource({ + fetchImpl: async (url, init) => { + signals.push(init.signal); + if (signals.length === 1) return fetchImpl(url, init); + return ok(photoPayload([])); + }, + }); + await source.getPhoto('1'); + assert.equal(signals[0].aborted, true); + assert.equal(seen.cancelled, true); + assert.equal(signals[1].aborted, false, 'a read answer is left alone'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index a9beb52c5..9aa4639c5 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -73,6 +73,11 @@ async function loadBitmap(url, timeoutMs) { } } +/** Free a decoded bitmap nobody will draw (an has nothing to free). */ +function release(image) { + image?.close?.(); +} + /** The panorama size a canvas this wide can show sharply at this field of view. */ export function panoSizeFor(canvasPixels, fovDeg) { const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); @@ -130,6 +135,10 @@ export function createKartaViewViewer({ let panoWidth = 0; /** The flat photo on screen is the 2,560 px version. */ let flatSharp = false; + /** The sharper version loading ({seq, kind}): a resize starts no second one. */ + let upgrading = null; + /** A resize came while it loaded: look again once it lands. */ + let upgradeAgain = false; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -143,6 +152,9 @@ export function createKartaViewViewer({ function emit() { if (!photo || !showing) return; + // A lost GPU context, still being recovered: the open that redraws the + // panorama poses once it is on screen. + if (showing === 'pano' && !renderer) return; const view = showing === 'pano' ? renderer.getView() : null; const heading = cameraBearing(photo); const pose = { @@ -485,20 +497,40 @@ export function createKartaViewViewer({ ? resizedImageUrl(target.urls.pano, size) : resizedImageUrl(target.urls.full, size); if (!url) return; + // Resizes come in bursts while the panel animates: one load at a time + // for this photo, and one more look once it lands. + if (upgrading?.seq === seq && upgrading.kind === kind) { + upgradeAgain = true; + return; + } + const pending = (upgrading = { seq, kind }); + upgradeAgain = false; + let image = null; try { - const image = await load(url, { crossOrigin: kind === 'pano' }); + image = await load(url, { crossOrigin: kind === 'pano' }); if (seq !== openSeq || photo !== target || showing !== kind) return; if (kind === 'pano') { if (!renderer || size[0] <= panoWidth) return; renderer.setImage(image); + image = null; panoWidth = size[0]; } else { picture.src = image.src; + image = null; flatSharp = true; } requestRender(); } catch { /* the first version stays */ + } finally { + release(image); + if (upgrading === pending) { + upgrading = null; + if (upgradeAgain && seq === openSeq && photo === target) { + upgradeAgain = false; + upgrade(target, seq, showing); + } + } } } @@ -524,10 +556,16 @@ export function createKartaViewViewer({ resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), { crossOrigin: true }, ); - if (seq !== openSeq) return false; + if (seq !== openSeq) { + release(image); + return false; + } // The context may have been lost while this loaded: build on the new // canvas, or let the caller show the front view. - if (!ensureRenderer()) throw new Error(LOAD_FAILED); + if (!ensureRenderer()) { + release(image); + throw new Error(LOAD_FAILED); + } const restore = restoreView?.id === target.id ? restoreView.view : null; restoreView = null; if (restore) renderer.setView(restore); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 519e76615..1391b727c 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -105,8 +105,11 @@ function setup({ rendererThrows = false, steps = {}, failSteps = false, + hold = () => false, } = {}) { const loads = []; + const held = []; + const closed = []; const renderers = []; const stepped = []; const asked = []; @@ -130,7 +133,10 @@ function setup({ loads.push({ url, crossOrigin: options?.crossOrigin }); if (failLoads.some((part) => url.includes(part))) throw new Error('The KartaView photo could not be loaded'); - return { src: url }; + // A decoded bitmap: close() frees it. + const image = { src: url, close: () => closed.push(url) }; + if (!hold(url)) return image; + return new Promise((resolve) => held.push(() => resolve(image))); }, createRenderer: (canvas) => { attempts.renderer++; @@ -152,6 +158,8 @@ function setup({ stepped, asked, attempts, + held, + closed, }; } @@ -923,3 +931,54 @@ test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px for (let i = 0; i < 4; i++) await settle(); assert.equal(loads.length, 2, 'once only'); }); + +test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => { + let first = true; + const { viewer, host, loads, renderers, held } = setup({ + hold: (url) => { + const holdIt = first && url.includes('rs:fit:2048'); + if (holdIt) first = false; + return holdIt; + }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + // The panel animates open: a burst of resizes while 2,048 px loads. + parts(host).canvas.clientWidth = 1400; + for (let i = 0; i < 5; i++) viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'the preview and the one 2,048 px load'); + held[0](); + assert.ok(await until(() => renderers[0].images.length === 3)); + assert.match(renderers[0].images[1], /rs:fit:2048:1024/); + assert.match(renderers[0].images[2], /rs:fit:4096:2048/); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 3, 'one more load, for the size asked last'); +}); + +test('a preview or sharper version nobody will draw is freed, not left decoded', async () => { + const { viewer, host, renderers, held, closed } = setup({ + hold: (url) => /rs:fit:(1024|2048):/.test(url), + }); + await viewer.mount(host); + // pano1's preview is still loading when flat1 opens over it. + const overtaken = viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + await viewer.open('flat1'); + held[0](); + await overtaken; + assert.deepEqual(closed.length, 1); + assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview'); + // pano1 shows; its sharp version lands after flat1 replaced it. + const shown = viewer.open('pano1'); + assert.ok(await until(() => held.length === 2)); + held[1](); + await shown; + assert.ok(await until(() => held.length === 3)); + await viewer.open('flat1'); + held[2](); + assert.ok(await until(() => closed.length === 2)); + assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version'); + assert.equal(renderers[0].images.length, 1, 'only the preview was drawn'); +}); From b42ab9bb02b7f7f1adf1e517e1caecfda10f0e31 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 14:54:12 +0100 Subject: [PATCH 30/78] feat(street-level): IMAGERY and SINCE filters for KartaView KartaView filters coverage on its server only in its PNG tiles (its JSON tiles come back empty with a filter), so a filtered tile's PNG is a mask over the cached JSON segments: a segment stays when at least half the points along it lie on drawn pixels. A filter change reloads the lines (lineCoverage.reload keeps the drawn ones until the new ones are in); masks are cached per filter and tile, and a filtered tile KartaView is still drawing (504 or a timeout) is asked again, four tries in all. The nearest lookup asks the photo API for the projection and date, and the cones follow the filter like Mapillary's. The gate fixture draws filtered PNG tiles and adds an older flat-only street; a new gate step checks 360 and LAST YEAR thin KartaView's lines and ALL brings them back. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- DATA_SOURCES.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 22 +- scripts/fixtures/street-level/kartaView.mjs | 163 +++++++++++++-- scripts/qa-street-level.mjs | 70 ++++++- src/layers/streetLevel/lineCoverage.js | 10 + .../streetLevel/providers/kartaview/decode.js | 116 ++++++++++- .../providers/kartaview/decode.test.mjs | 59 ++++++ .../streetLevel/providers/kartaview/index.js | 80 ++++++-- .../streetLevel/providers/kartaview/policy.js | 65 ++++++ .../providers/kartaview/policy.test.mjs | 27 +++ .../providers/kartaview/provider.test.mjs | 190 +++++++++++++++--- .../streetLevel/providers/kartaview/source.js | 73 ++++++- .../providers/kartaview/source.test.mjs | 72 +++++++ src/tooling/streetLevelQa.test.mjs | 82 ++++++++ 16 files changed, 961 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe3f8201..cde9b6883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,11 @@ answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and pinch look around a 360° photo. The open photo's marker is a purple view cone pointing along the drive (KartaView's own compass headings are often - 0 or far off). + 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to + KartaView as to Mapillary: its lines are filtered by KartaView's own + server (its filtered PNG tiles, as a mask over the JSON lines; SINCE goes + by upload date there), and the photo a click opens, and the cones, by + each photo's projection and capture date. Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 23d9cfc82..1319c1541 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -22,7 +22,7 @@ How to read this: | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | -| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index e55c94a77..9e914764b 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView filters its lines on its own server, by upload date for **SINCE** (never before the capture date, so a line may hold only older captures: the click then says none of its photos pass), and a photo opened is held to its capture date; a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. 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.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `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 7c98f62fb..89644fae5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3212,8 +3212,26 @@ segments that meet end to start (about four times fewer lines; linear time, at most 20,000 segments a tile and 5,000 points a line), they are drawn as `kv:line:`, up to 1,600 per tile, and never highlighted (`highlightsSequence: false`, so the legend has no "Selected"), and the -provider declares `filters: []` and `coverageLabel` ("N streets in view · -click a line for its nearest photo"). Tiles come uncompressed and +provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N +streets in view · click a line for its nearest photo"). KartaView filters +coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`; +its JSON, GeoJSON and jsoncanvas tiles come back empty once a filter is +asked for), so a filtered tile is drawn from the cached JSON segments that +lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the +points sampled every 2 px have a drawn pixel within one; on Jakarta's 360° +filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between). +A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show +until the new ones are in), keeps the JSON segments, and caches masks per +filter and tile. A dense filtered PNG can take KartaView over a minute the +first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is +asked again 4 s later, four tries in all; then the panel says it did not +filter the area in time. KartaView's `startDate` is the upload date, never +before the capture date, so the lines are a superset of what passes; the +nearest lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it +ignores `fieldOfView`) and upload date, then holds each photo to its capture +date (when photos are near but none passes, the panel says so), and the +cones pass the filter on each photo's projection and capture date, as +Mapillary's do. Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 1c85b0ec0..91caa8004 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -1,3 +1,4 @@ +import { deflateSync } from 'node:zlib'; import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; @@ -11,6 +12,10 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; * so the nearest photo opened is even and › must skip the one after it. The * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as * KartaView does under load. + * + * Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the + * imagery filter drops it. KartaView filters coverage only in its PNG + * tiles, so filtered PNG tiles are drawn here for the streets that pass. */ export const KV_SEQUENCE_ID = '900001'; @@ -19,9 +24,20 @@ export const KV_LINE = Object.freeze({ south: 38.5795, north: 38.5875, }); +/** A street ~130 m east of the drive with only flat 2019 photos. */ +export const KV_OLD_STREET = Object.freeze({ + lon: -121.4916, + south: 38.58, + north: 38.587, +}); +const OLD_STREET_UPLOADED = '2019-06-01'; const KV_SPACING_M = 25; const METRES_PER_DEG_LAT = 110_540; -const SHOT = '2024-05-02 16:20:00.000'; +/** KartaView's time format for `ms`. */ +const kartaViewTime = (ms) => + new Date(ms).toISOString().replace('T', ' ').slice(0, 23); +/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */ +const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000); const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); @@ -95,27 +111,123 @@ const raw = (status, body) => ({ body: JSON.stringify(body), }); -/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */ -function tileLines(z, x, y) { - const { west, east, south, north } = tileBounds(x, y, z); - if (KV_LINE.lon < west || KV_LINE.lon > east) return []; - const from = Math.max(KV_LINE.south, south); - const to = Math.min(KV_LINE.north, north); - if (from >= to) return []; - // Two segments meeting end to start, as KartaView splits ways. - const mid = (from + to) / 2; - const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) }); +/** The streets with photos: the drive, and the old street beside it. */ +function streets() { return [ - { track: [at(from), at(mid)], matched: '1' }, - { track: [at(mid), at(to)], matched: '1' }, + { line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] }, + { line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] }, ]; } +/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */ +function stretch(line, z, x, y) { + const { west, east, south, north } = tileBounds(x, y, z); + if (line.lon < west || line.lon > east) return null; + const from = Math.max(line.south, south); + const to = Math.min(line.north, north); + return from < to ? [from, to] : null; +} + +/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */ +function tileLines(z, x, y) { + const lines = []; + for (const { line } of streets()) { + const inside = stretch(line, z, x, y); + if (!inside) continue; + const [from, to] = inside; + // Two segments meeting end to start, as KartaView splits ways. + const mid = (from + to) / 2; + const at = (lat) => ({ lat: String(lat), lng: String(line.lon) }); + lines.push( + { track: [at(from), at(mid)], matched: '1' }, + { track: [at(mid), at(to)], matched: '1' }, + ); + } + return lines; +} + +/** Whether a street has photos passing a filtered tile's query. */ +function passesTileQuery(street, query) { + const view = query.get('fieldOfView'); + if (view && !street.views.includes(view)) return false; + const since = query.get('startDate'); + return !since || street.uploaded >= since; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function pngChunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** + * A filtered coverage tile as KartaView draws it: 512 px, transparent, the + * passing streets three pixels wide. + */ +export function maskTilePng(z, x, y, query) { + const size = 512; + const rgba = Buffer.alloc(size * size * 4); + const { west, east } = tileBounds(x, y, z); + const n = 2 ** z; + const row = (lat) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return Math.round((my * n - y) * size); + }; + for (const street of streets()) { + if (!passesTileQuery(street, query)) continue; + const inside = stretch(street.line, z, x, y); + if (!inside) continue; + const column = Math.round( + ((street.line.lon - west) / (east - west)) * size, + ); + const top = Math.max(0, row(inside[1])); + const bottom = Math.min(size - 1, row(inside[0])); + for (let py = top; py <= bottom; py++) + for (let px = column - 1; px <= column + 1; px++) { + if (px < 0 || px >= size) continue; + rgba.set([99, 91, 255, 255], (py * size + px) * 4); + } + } + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let py = 0; py < size; py++) + rgba.copy( + rows, + py * (size * 4 + 1) + 1, + py * size * 4, + (py + 1) * size * 4, + ); + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(rows)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + /** A call named by host, path shape and parameter names. */ export function describeKartaViewCall(method, url) { const keys = [...url.searchParams.keys()].sort(); const path = url.pathname .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') + .replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png') .replace(/\/photo\/\d+$/, '/photo/{id}') .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') .replace(/\/sequence\/\d+$/, '/sequence/{id}') @@ -173,6 +285,23 @@ export function answerKartaViewRequest({ method, url }) { }), known: true, }; + // Filtered coverage: KartaView filters only its PNG tiles. + const mask = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/, + ); + if (mask) + return { + status: 200, + contentType: 'image/png', + headers: CORS, + body: maskTilePng( + Number(mask[3]), + Number(mask[1]), + Number(mask[2]), + address.searchParams, + ), + known: true, + }; if (path === '/2.0/photo/') { if (nearestLookups++ === 0) return { @@ -186,7 +315,15 @@ export function answerKartaViewRequest({ method, url }) { }; const q = address.searchParams; const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + // Its own filter, as KartaView's: projection, and upload date. + const projection = q.get('projection'); + const since = q.get('startDate'); const near = photos + .filter( + (photo) => + (!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) && + (!since || SHOT.slice(0, 10) >= since), + ) .map((photo) => ({ photo, metres: metresApart(at, photo) })) .filter(({ metres }) => metres < 200) .sort((a, b) => a.metres - b.metres) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 4b60a0695..e5f76fe8c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,14 +888,14 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); await waitForCoverage(page); const u = await ui(); assert.equal(u.keyRequired, false); - assert.deepEqual(u.filterControls, { pano: false, since: false }); + assert.deepEqual(u.filterControls, { pano: true, since: true }); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -924,6 +924,72 @@ async function main() { ); }, ); + await step( + 'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back', + async () => { + const before = (await ui()).coverage.count; + const setStop = (index) => + page.$eval( + '#sl-since', + (input, value) => { + input.value = String(value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, + index, + ); + // Filtered tiles are drawn by KartaView on demand: give them time. + const settled = async (test, arg, what) => + assert.ok( + await uiUntil(test, arg, { timeout: 60_000 }), + `${what}: ${JSON.stringify((await ui()).coverage)}`, + ); + await clickPanelControl(page, '[data-sl-pano="pano"]'); + await settled( + (u) => u.filter.pano === 'pano' && !u.coverage.loading, + null, + '360° drawn', + ); + const panos = (await ui()).coverage.count; + assert.ok(panos <= before, `${panos} ≤ ${before}`); + // Fixtures: the old street has only flat photos, the drive 360° too. + if (fixtures) + assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`); + await clickPanelControl(page, '[data-sl-pano="all"]'); + await settled( + (u, n) => + u.filter.pano === 'all' && + !u.coverage.loading && + u.coverage.count === n, + before, + 'every line again', + ); + await setStop(5); + await settled( + (u) => u.filter.sinceDays === 365 && !u.coverage.loading, + null, + 'LAST YEAR drawn', + ); + const lastYear = (await ui()).coverage.count; + assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`); + // Fixtures: the drive is a month old, the old street from 2019. + if (fixtures) + assert.ok( + lastYear > 0 && lastYear < before, + `0 < ${lastYear} < ${before}`, + ); + await setStop(0); + await settled( + (u, n) => + u.filter.sinceDays === 0 && + !u.coverage.loading && + u.coverage.count === n, + before, + 'ANY DATE: every line again', + ); + assert.equal((await ui()).coverage.error, null); + }, + ); await step( 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 85edd97c1..71b98ed6a 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -798,6 +798,15 @@ export function createLineCoverage({ notify(); } + /** + * Load every tile again (a source that filters on its server had its + * filter changed); the lines drawn now show until the new ones are in. + */ + function reload() { + retire(); + refresh(); + } + /** * Redraw tiles draped or cast for the new surface mode, then re-pick the * zoom, since the ground height under the camera may have changed. @@ -919,6 +928,7 @@ export function createLineCoverage({ clear, resetErrors, rebuild, + reload, setSurface, recolorSequence, sequenceCount, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index b5488af00..bee93d76c 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -1,19 +1,19 @@ +import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js'; + /** More than any real tile holds (a dense z13 tile has ~4,700 segments). */ export const MAX_TILE_SEGMENTS = 20_000; /** A chained line stops growing here and the next one starts. */ export const MAX_LINE_POINTS = 5_000; /** - * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into - * line records for the shared coverage. The tile's lines are OSM way - * segments with photos; segments that meet end to start are chained, which - * cuts the line count about fourfold. Lines carry no sequence, date or - * projection, so nothing can filter them. + * The way segments of a KartaView coverage tile + * (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines + * carry no sequence, date or projection: a filter thins them through a mask + * (see `maskedSegments`). * @param {object} payload - * @param {{x: number, y: number, z: number}} address - * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + * @returns {Array>} */ -export function decodeCoverageTile(payload, { x, y, z }) { +export function tileSegments(payload) { // Not cached as empty: an unreadable tile is asked for again. if (!Array.isArray(payload?.lines)) throw new Error('KartaView sent an unreadable coverage tile'); @@ -31,6 +31,18 @@ export function decodeCoverageTile(payload, { x, y, z }) { } if (points.length >= 2) segments.push(points); } + return segments; +} + +/** + * Segments chained into line records for the shared coverage: segments + * that meet end to start are joined, which cuts the line count about + * fourfold. + * @param {Array>} segments + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +export function chainSegments(segments, { x, y, z }) { const key = ([lon, lat]) => `${lon},${lat}`; const startingAt = new Map(); segments.forEach((segment, index) => { @@ -74,3 +86,91 @@ export function decodeCoverageTile(payload, { x, y, z }) { }); return { sequences, overview: [] }; } + +/** Decode a KartaView coverage tile into the shared coverage's line records. */ +export function decodeCoverageTile(payload, address) { + return chainSegments(tileSegments(payload), address); +} + +/** + * The segments a filtered PNG tile of the same address draws over. The PNG + * draws the same ways a few pixels wide (on a transparent ground): a + * segment is kept when at least half the points sampled along it have a + * drawn pixel within one pixel. Segments either lie on it or miss it + * almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33 + * between). A segment with no point on the tile is not this tile's to keep. + * @param {Array>} segments + * @param {{width: number, height: number, alpha: ArrayLike}} mask one alpha byte per pixel + * @param {{x: number, y: number, z: number}} address + */ +export function maskedSegments(segments, mask, { x, y, z }) { + const { width, height, alpha } = mask; + const n = 2 ** z; + const toPixel = ([lon, lat]) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height]; + }; + const drawnNear = (px, py) => { + const cx = Math.floor(px); + const cy = Math.floor(py); + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + const X = cx + dx; + const Y = cy + dy; + if (X < 0 || Y < 0 || X >= width || Y >= height) continue; + if (alpha[Y * width + X] > 0) return true; + } + return false; + }; + return segments.filter((segment) => { + let on = 0; + let tested = 0; + let [ax, ay] = toPixel(segment[0]); + for (let i = 1; i < segment.length; i++) { + const [bx, by] = toPixel(segment[i]); + const steps = Math.max( + 1, + Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX), + ); + // Each point once: a segment's next stretch starts where this one ends. + for (let j = i === 1 ? 0 : 1; j <= steps; j++) { + const px = ax + ((bx - ax) * j) / steps; + const py = ay + ((by - ay) * j) / steps; + if (px < 0 || py < 0 || px >= width || py >= height) continue; + tested++; + if (drawnNear(px, py)) on++; + } + ax = bx; + ay = by; + } + return tested > 0 && on / tested >= MASK_KEEP_SHARE; + }); +} + +/** + * A PNG mask's alpha, one byte per pixel, decoded off the main thread + * where the browser can. + * @param {Uint8Array} bytes the PNG + * @returns {Promise<{width: number, height: number, alpha: Uint8Array}>} + */ +export async function readMaskImage(bytes) { + const bitmap = await createImageBitmap( + new Blob([bytes], { type: 'image/png' }), + ); + try { + const { width, height } = bitmap; + const canvas = + typeof OffscreenCanvas === 'function' + ? new OffscreenCanvas(width, height) + : Object.assign(document.createElement('canvas'), { width, height }); + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.drawImage(bitmap, 0, 0); + const rgba = context.getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3]; + return { width, height, alpha }; + } finally { + bitmap.close?.(); + } +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index e6cc498d6..619719008 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -3,7 +3,10 @@ import test from 'node:test'; import { MAX_LINE_POINTS, MAX_TILE_SEGMENTS, + chainSegments, decodeCoverageTile, + maskedSegments, + tileSegments, } from './decode.js'; const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); @@ -126,3 +129,59 @@ test('a tile is capped in segments and in points per line', () => { sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS), ); }); + +/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */ +function lonLatAt(px, py, { x, y, z } = TILE, size = 512) { + const n = 2 ** z; + const lon = ((x + px / size) / n) * 360 - 180; + const my = Math.PI * (1 - (2 * (y + py / size)) / n); + const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI; + return [lon, lat]; +} + +/** A 512 px mask drawn on the given rows. */ +function rowsMask(rows) { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; +} + +test('a mask keeps the segments it draws over, chained as before', () => { + const along = (row, from = 40, to = 470) => [ + lonLatAt(from, row), + lonLatAt(to, row), + ]; + const segments = [ + along(100), // drawn at row 100 + along(101.6), // a pixel off: still drawn within one pixel + along(300), // not drawn + // Two thirds along the drawn row, then off it: kept (at least half). + [lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)], + // A third along it, then off: dropped. + [lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)], + // Off the tile entirely: not this tile's to keep. + [lonLatAt(-200, 100), lonLatAt(-20, 100)], + ]; + const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE); + assert.deepEqual(kept, [segments[0], segments[1], segments[3]]); + assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []); + // A full mask keeps every segment that is on the tile. + assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5); +}); + +test('decoding is segments then chaining: the same lines either way', () => { + const payload = { + lines: [ + { track: [at(1, 1), at(2, 1)] }, + { track: [at(2, 1), at(3, 1)] }, + { track: [at(5, 5), at(6, 6)] }, + ], + }; + const segments = tileSegments(payload); + assert.equal(segments.length, 3); + assert.deepEqual( + chainSegments(segments, TILE), + decodeCoverageTile(payload, TILE), + ); + assert.throws(() => tileSegments({}), /unreadable coverage tile/); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index da093112b..cf932462b 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,7 +1,13 @@ +import { passesImageryFilter } from '../../filter.js'; import { createLineCoverage } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; -import { decodeCoverageTile } from './decode.js'; +import { + chainSegments, + maskedSegments, + readMaskImage, + tileSegments, +} from './decode.js'; import { createKartaViewSource } from './source.js'; import { createKartaViewViewer } from './viewer.js'; import { @@ -11,6 +17,7 @@ import { COVERAGE_NOUN, COVERAGE_STREET_ZOOM_BELOW_M, COVERAGE_TUNING, + FILTERED_OUT_MESSAGE, KARTAVIEW_CREDIT_HTML, KARTAVIEW_LABEL, KARTAVIEW_NAME, @@ -27,13 +34,14 @@ import { assignCourses, cameraBearing, normalizePhoto, + tileFilterQuery, } from './policy.js'; /** Photos kept in memory by id (sequence pages fill it). */ const PHOTO_CACHE_SIZE = 4000; /** Sequence pages kept in memory. */ const PAGE_CACHE_SIZE = 12; -/** Decoded coverage tiles kept, and for how long. */ +/** Decoded coverage tiles kept, and for how long (masks too). */ const TILE_CACHE_SIZE = 24; const TILE_CACHE_TTL_MS = 10 * 60_000; @@ -77,11 +85,12 @@ export function createTileCache({ size = TILE_CACHE_SIZE, ttlMs = TILE_CACHE_TTL_MS, now = Date.now, + keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`, }) { const kept = createLru(size); return { async get(tile, options) { - const key = `${tile.z}/${tile.x}/${tile.y}`; + const key = keyOf(tile); const entry = kept.get(key); if (entry && now() - entry.at < ttlMs) return entry.tile; const loaded = await load(tile, options); @@ -102,12 +111,13 @@ function pageOf(index) { * KartaView Street Level provider: keyless coverage lines from KartaView's * tile API, the photos nearest a clicked line, cones along the photo's * sequence, and a flat or 360° viewer with ‹ › along the sequence. - * @param {{source?: ReturnType, viewerOptions?: object}} [options] + * @param {{source?: ReturnType, viewerOptions?: object, readMask?: typeof readMaskImage}} [options] * @returns {import('../../registry.js').StreetLevelProvider} */ export function createKartaViewProvider({ source = createKartaViewSource(), viewerOptions = {}, + readMask = readMaskImage, } = {}) { return Object.freeze({ id: KARTAVIEW_PROVIDER_ID, @@ -120,8 +130,9 @@ export function createKartaViewProvider({ marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), defaultOn: false, - // Coverage lines carry no date or projection: no filter could thin them. - filters: [], + // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on + // its server (see tileFilterQuery), the photos by their own record. + filters: Object.freeze(['pano', 'since']), // Its lines are streets, not sequences: a selected drive shows as cones. highlightsSequence: false, coverageLabel: Object.freeze({ @@ -315,12 +326,30 @@ export function createKartaViewProvider({ return probe; } - // Decoded coverage tiles, kept a while: panning back asks nothing. + // A tile's way segments, kept a while: panning back, or a filter + // change, asks KartaView nothing more for them. const tiles = createTileCache({ load: async (tile, { signal }) => - decodeCoverageTile(await source.getTile(tile, { signal }), tile), + tileSegments(await source.getTile(tile, { signal })), }); - const fetchTile = (tile, options) => tiles.get(tile, options); + // A filtered tile's mask, by filter and tile. + const masks = createTileCache({ + load: async ({ query, ...tile }, { signal }) => + readMask(await source.getTileMask(tile, query, { signal })), + keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`, + }); + /** The tile's lines under the filter now: all, or the masked ones. */ + async function fetchTile(tile, options) { + const query = tileFilterQuery(context.getFilter()); + if (!query) return chainSegments(await tiles.get(tile, options), tile); + const [segments, mask] = await Promise.all([ + tiles.get(tile, options), + masks.get({ ...tile, query }, options), + ]); + return chainSegments(maskedSegments(segments, mask, tile), tile); + } + /** The query the drawn lines were loaded under. */ + let drawnQuery = tileFilterQuery(context.getFilter()); /** * The next showable photo `delta` along the sequence, or null at its @@ -361,6 +390,7 @@ export function createKartaViewProvider({ renderKey: 'kartaview-coverage', tuning: COVERAGE_TUNING, messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT }, + // Filtered on the way in (fetchTile): a line has no date to test. passes: () => true, }); @@ -395,8 +425,7 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', - // The filters are hidden for KartaView: one left set must not hide cones. - passes: () => true, + // The imagery filter applies to cones as to Mapillary's (capture date). }); /** Sequence id → its photo count from KartaView (asked once). */ @@ -491,6 +520,7 @@ export function createKartaViewProvider({ drawnPage = null; // Tens of MB of decoded lines are not kept for a source left off. tiles.clear(); + masks.clear(); }, destroy(cesiumViewer) { @@ -499,12 +529,23 @@ export function createKartaViewProvider({ coverage.resetErrors(); cones.destroy(cesiumViewer); tiles.clear(); + masks.clear(); }, refreshCoverage: () => coverage.refresh(), - /** No filter applies (see `filters`). */ - setFilter() {}, + /** + * The filter changed: the lines come from KartaView again under it + * (the old ones show meanwhile), the cones are drawn again. + */ + setFilter() { + const query = tileFilterQuery(context.getFilter()); + if (query !== drawnQuery) { + drawnQuery = query; + coverage.reload(); + } + cones.rerender(); + }, setSurface() { coverage.setSurface(); @@ -568,13 +609,21 @@ export function createKartaViewProvider({ * file at all, so the nearest few are tried in turn. */ async nearestImage({ lat, lon }, { signal } = {}) { - const records = await source.nearestPhotos({ lat, lon }, { signal }); + const filter = context.getFilter(); + const records = await source.nearestPhotos( + { lat, lon, filter }, + { signal }, + ); const near = []; + let filteredOut = 0; for (const record of records) { const photo = remember(record); if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); - if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres }); + if (metres > NEAREST_RADIUS_M) continue; + // KartaView filtered by upload date: hold it to the capture date. + if (!passesImageryFilter(photo, filter)) filteredOut++; + else near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); // Probed side by side (photo storage is not the API host): the @@ -586,6 +635,7 @@ export function createKartaViewProvider({ const hit = candidates.find((_, i) => answers[i]); if (hit) return hit.photo.id; if (near.length) throw new Error(ARCHIVED_MESSAGE); + if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE); return null; }, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 9930f2c5e..d77353d6c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -111,6 +111,71 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); export const KARTAVIEW_CREDIT_HTML = 'Street Level: imagery © KartaView contributors, CC BY-SA 4.0'; +/** + * The imagery filter (360° or flat, SINCE). KartaView filters coverage on + * its server only in its PNG tiles: its JSON tiles come back empty once a + * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is + * kept where it lies on the mask's drawn pixels. + * + * KartaView's date filter is the upload date, never before the capture + * date: the lines are the streets with photos uploaded since the cut-off, + * and a photo opened from them is held to its capture date. + */ +export const MASK_TIMEOUT_MS = 20_000; +/** + * A filtered PNG tile is drawn on demand and a dense one can outlast + * KartaView's own 60 s (504); it keeps drawing, and has the tile a few + * seconds later. A timeout or a 5xx is asked again this many times in all. + */ +export const MASK_ATTEMPTS = 4; +export const MASK_RETRY_WAIT_MS = 4_000; +/** Real masks are tens of kilobytes. */ +export const MASK_MAX_BYTES = 2 * 1024 * 1024; +/** A segment is kept when this share of the points along it lies on the mask. */ +export const MASK_KEEP_SHARE = 0.5; +/** Points along a segment are tested this many mask pixels apart. */ +export const MASK_SAMPLE_PX = 2; +export const MASK_SLOW_MESSAGE = + 'KartaView did not filter this area in time — move the map to ask again'; +export const FILTERED_OUT_MESSAGE = + 'None of KartaView’s photos here pass the imagery filter'; + +/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ +function utcDay(ms) { + return new Date(ms).toISOString().slice(0, 10); +} + +const filtersSince = (filter) => + Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; + +/** + * The query of a filtered PNG coverage tile, or '' when nothing is + * filtered. The day, not the instant, so it is stable for a day (and keys + * the mask cache). + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + */ +export function tileFilterQuery(filter) { + const params = new URLSearchParams(); + if (filter?.pano === 'pano') params.set('fieldOfView', '360'); + else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane'); + if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs)); + return params.toString(); +} + +/** + * The photo search's own filter: projection and upload date (its + * `fieldOfView` is ignored). The capture date is checked on the answers. + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + * @returns {Record} + */ +export function photoFilterParams(filter) { + const params = {}; + if (filter?.pano === 'pano') params.projection = 'SPHERE'; + else if (filter?.pano === 'flat') params.projection = 'PLANE'; + if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs); + return params; +} + /** The cone and coverage noun: a line is a street with photos, not a sequence. */ export const COVERAGE_NOUN = 'streets'; export const LINE_CLICK_HINT = 'click a line for its nearest photo'; diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 0698a9e8a..fe23b8f48 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -7,7 +7,9 @@ import { kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, + photoFilterParams, resizedImageUrl, + tileFilterQuery, } from './policy.js'; /** A photo record as /2.0/photo returns it (trimmed to what is read). */ @@ -280,3 +282,28 @@ test('the camera looks along the drive: each photo’s course points to the next 1e-9, ); }); + +test('the imagery filter becomes KartaView’s tile query and photo-search params', () => { + // 2026-03-05 23:30 UTC: the cut-off day is the UTC one. + const sinceMs = Date.UTC(2026, 2, 5, 23, 30); + assert.equal(tileFilterQuery(null), ''); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), ''); + assert.equal( + tileFilterQuery({ pano: 'pano', sinceMs: null }), + 'fieldOfView=360', + ); + assert.equal( + tileFilterQuery({ pano: 'flat', sinceMs }), + 'fieldOfView=plane&startDate=2026-03-05', + ); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), ''); + // The search ignores fieldOfView; its projection filters instead. + assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {}); + assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), { + projection: 'SPHERE', + startDate: '2026-03-05', + }); + assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), { + projection: 'PLANE', + }); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index ea82db277..013a510f7 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -6,6 +6,7 @@ import { kartaViewZoomFor, } from './index.js'; import { + FILTERED_OUT_MESSAGE, NO_COVERAGE_HINT, SEQUENCE_PAGE_SIZE, TOO_HIGH_HINT, @@ -36,13 +37,23 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { }; } +/** KartaView's shotDate for `ms` ago. */ +const shotAgo = (ms) => + new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23); + /** * A source over `sequences` (id → indexes present, in page order: KartaView * pages follow photo ids, which only roughly follow the index), recording * calls. Photos whose id is in `archived` answer 409. */ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { - const calls = { nearest: 0, photo: [], pages: [], probes: [] }; + const calls = { + nearest: 0, + nearestFilters: [], + photo: [], + pages: [], + probes: [], + }; return { calls, probeImage: async (url) => { @@ -50,8 +61,9 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { return !archived.some((id) => url.includes(`/${id}.jpg`)); }, getTile: async () => ({ lines: [] }), - nearestPhotos: async () => { + nearestPhotos: async ({ filter }) => { calls.nearest++; + calls.nearestFilters.push(filter); return nearest; }, getPhoto: async (id) => { @@ -80,12 +92,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { } function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { - const actions = { opened: [], nearest: [], instance: null }; + // `actions.filter` is the resolved filter now; a test may change it. + const actions = { opened: [], nearest: [], instance: null, filter }; return { actions, context: { services: {}, - getFilter: () => filter, + getFilter: () => actions.filter, isActive: () => true, groundCaster: null, meshSampler: null, @@ -105,11 +118,12 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { }; } -function setup(sourceOptions, contextOptions) { +function setup(sourceOptions, contextOptions, { readMask } = {}) { const source = fakeSource(sourceOptions); const loads = []; const definition = createKartaViewProvider({ source, + readMask, viewerOptions: { load: async (url) => { loads.push(url); @@ -128,12 +142,12 @@ function setup(sourceOptions, contextOptions) { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('KartaView registers keyless, off by default, with no filters and its own wording', () => { +test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => { const { definition } = setup(); assert.equal(definition.id, 'kartaview'); assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); - assert.deepEqual(definition.filters, []); + assert.deepEqual(definition.filters, ['pano', 'since']); assert.equal( definition.highlightsSequence, false, @@ -195,6 +209,38 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing ); }); +test('the nearest photo passes the imagery filter by its capture date; none passing says so', async () => { + const since = Date.now() - 86_400_000; + const filter = { pano: 'pano', sinceMs: since }; + // KartaView filters the search by upload date: an old capture uploaded + // today still comes back, and is held to its capture date here. + const pano = (id, metres, shot) => ({ + ...photoRecord(id, { metres }), + projection: 'SPHERE', + fieldOfView: '360', + shotDate: shot, + }); + const { instance, source } = setup( + { + nearest: [ + pano('old', 5, '2016-08-23 20:17:47.000'), + pano('new', 20, shotAgo(3_600_000)), + ], + }, + { filter }, + ); + const at = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await instance.nearestImage(at), 'new'); + assert.deepEqual(source.calls.nearestFilters, [filter]); + const old = setup( + { nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] }, + { filter }, + ); + await assert.rejects(old.instance.nearestImage(at), { + message: FILTERED_OUT_MESSAGE, + }); +}); + test('a cone opens its photo; a line opens the photo nearest the click', () => { const { instance, actions } = setup(); actions.instance = null; @@ -617,34 +663,72 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from', ); }); -test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => { +test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => { const env = await mounted( { sequences: { s: [0, 1, 2, 3] } }, { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, ); - // Undated records: no capture time to pass a "since" filter with. - const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' }); + // 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016. + const shaped = (record) => + Number(record.sequenceIndex) < 2 + ? { + ...record, + projection: 'SPHERE', + fieldOfView: '360', + shotDate: shotAgo(3_600_000), + } + : record; const getPhoto = env.source.getPhoto; - env.source.getPhoto = async (id) => undated(await getPhoto(id)); + env.source.getPhoto = async (id) => shaped(await getPhoto(id)); const getPage = env.source.getSequencePhotos; env.source.getSequencePhotos = async (...args) => { const page = await getPage(...args); - return { ...page, photos: page.photos.map(undated) }; + return { ...page, photos: page.photos.map(shaped) }; }; const cones = await withCones(t, env); await env.instance.viewer.open('s-1'); await env.instance.selectSequence('s'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); - assert.equal(env.instance.sequenceStats().images, 4); - assert.equal(cones.count(), 4, 'every cone drawn'); + assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); + assert.equal(cones.count(), 2, 'the recent 360° photos'); + assert.deepEqual(cones(), [0, 1]); + env.actions.filter = { pano: 'flat', sinceMs: null }; + env.instance.setFilter(); + assert.deepEqual(cones(), [2, 3], 'the flat ones'); + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.equal(cones.count(), 4, 'every cone, unfiltered'); }); /** * The provider drawing coverage on a stand-in viewer above one z14 tile. * `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held. */ -function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { - const env = setup({}, { filter }); +function coverageSetup( + t, + { lines = [], filter, height = 900, drawnRows = () => [] } = {}, +) { + // A filtered tile's mask: 512 px square, drawn on the rows + // `drawnRows(query)` lists as [from, to]. + const env = setup( + {}, + { filter }, + { + readMask: async ({ query }) => { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of drawnRows(query)) + alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; + }, + }, + ); + const masks = []; + let maskHold = null; + env.source.getTileMask = async (tile, query) => { + masks.push(query); + await maskHold; + return { query }; + }; const centre = tileCentre(-83.0458, 42.3314); const viewer = coverageViewer( { lon: centre.lon, lat: centre.lat, height }, @@ -671,6 +755,15 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { viewer, centre, tiles, + masks, + holdMasks() { + let release; + maskHold = new Promise((resolve) => (release = resolve)); + return () => { + maskHold = null; + release(); + }; + }, holdTiles() { let release; hold = new Promise((resolve) => (release = resolve)); @@ -682,20 +775,59 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { }; } -test('a leftover imagery filter hides no KartaView line', async (t) => { - const { instance, viewer, centre } = coverageSetup(t, { - filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 }, - lines: [ - [ - [-83.0458 - 0.001, 42.3314], - [-83.0458 + 0.001, 42.3314], - ], - ], +test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => { + const { lon, lat } = tileCentre(-83.0458, 42.3314); + // Across the tile's middle row (mask row ~256), and a quarter up (~130). + const across = (at) => [ + [lon - 0.001, at], + [lon + 0.001, at], + ]; + const env = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: null }, + lines: [across(lat), across(lat + 0.004)], + // 360° photos lie on the middle line only. + drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []), }); - assert.ok(centre); - instance.activate(viewer); - assert.ok(await until(() => instance.coverageStats().count === 1)); - assert.equal(instance.coverageStats().hint, ''); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.deepEqual(env.masks, ['fieldOfView=360']); + // Unfiltered: every line, and the tile's lines are not asked for again. + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().count === 2)); + assert.equal(env.tiles.length, 1, 'the segments are kept'); + // SINCE a day: uploaded since yesterday (UTC) — none here. The lines + // drawn now stay on the globe while KartaView draws the filtered tile. + const since = Date.now() - 86_400_000; + const release = env.holdMasks(); + env.actions.filter = { pano: 'all', sinceMs: since }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.ok( + env.viewer.scene.groundPrimitives.items.size > 0, + 'the unfiltered lines show meanwhile', + ); + release(); + assert.ok( + await until( + () => + !env.instance.coverageStats().loading && + env.instance.coverageStats().count === 0, + ), + ); + assert.equal( + env.masks.at(-1), + `startDate=${new Date(since).toISOString().slice(0, 10)}`, + ); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); + assert.ok( + await until(() => env.viewer.scene.groundPrimitives.items.size === 0), + 'and go once it is in', + ); + // The same filter again asks KartaView for nothing. + env.instance.setFilter(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(env.masks.length, 2); }); test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 4283cf859..ddf21817f 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -2,6 +2,11 @@ import { IMAGE_PROBE_TIMEOUT_MS, MAX_RESPONSE_BYTES, KARTAVIEW_API_HOST, + MASK_ATTEMPTS, + MASK_MAX_BYTES, + MASK_RETRY_WAIT_MS, + MASK_SLOW_MESSAGE, + MASK_TIMEOUT_MS, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, PAGE_CONCURRENCY, @@ -14,6 +19,7 @@ import { PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, TILE_TIMEOUT_MS, + photoFilterParams, } from './policy.js'; /** @@ -121,19 +127,32 @@ export function createLimiter(limit, { lifo = false } = {}) { * waits behind slow tiles or pages. The photo API answers 429 to more than * about three requests at once; a 429 (and a lookup that timed out) is * retried. - * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options] + * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options] */ export function createKartaViewSource({ fetchImpl = (...args) => globalThis.fetch(...args), apiHost = KARTAVIEW_API_HOST, retryBaseMs = PHOTO_API_RETRY_BASE_MS, + maskRetryMs = MASK_RETRY_WAIT_MS, } = {}) { const limiter = createLimiter(PHOTO_API_CONCURRENCY); const tileLimiter = createLimiter(TILE_CONCURRENCY); const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true }); /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ - async function getJson(url, { signal, timeoutMs }) { + function getJson(url, { signal, timeoutMs }) { + return fetchBody(url, { signal, timeoutMs, as: 'json' }); + } + + /** + * GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON + * or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the + * timeout. + */ + async function fetchBody( + url, + { signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES }, + ) { const controller = new AbortController(); const onAbort = () => controller.abort(signal.reason); if (signal?.aborted) throw signal.reason; @@ -164,12 +183,14 @@ export function createKartaViewSource({ new KartaViewSourceError('KartaView sent too much data', { status: response.status, }); - if (length > MAX_RESPONSE_BYTES) throw tooMuch(); + if (length > maxBytes) throw tooMuch(); // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); if (!reader) { read = true; try { + if (as === 'bytes') + return new Uint8Array(await response.arrayBuffer()); return await response.json(); } catch (error) { if (controller.signal.aborted) throw error; @@ -185,7 +206,7 @@ export function createKartaViewSource({ const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { + if (bytes > maxBytes) { reader.cancel().catch(() => {}); throw tooMuch(); } @@ -197,6 +218,7 @@ export function createKartaViewSource({ body.set(chunk, offset); offset += chunk.byteLength; } + if (as === 'bytes') return body; try { return JSON.parse(new TextDecoder().decode(body)); } catch { @@ -304,6 +326,41 @@ export function createKartaViewSource({ } }, + /** + * A filtered coverage tile as PNG bytes, `query` from tileFilterQuery + * (KartaView filters only its PNG tiles). It draws a filtered tile on + * demand; a dense one can outlast its own 60 s (504) and still be drawn, + * so a timeout or a 5xx is asked again a few seconds later. + */ + async getTileMask({ x, y, z }, query, { signal } = {}) { + const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`; + for (let attempt = 1; ; attempt++) { + try { + // Outside the gate between tries: a wait holds no connection. + return await tileLimiter.run( + () => + fetchBody(url, { + signal, + timeoutMs: MASK_TIMEOUT_MS, + as: 'bytes', + maxBytes: MASK_MAX_BYTES, + }), + { signal }, + ); + } catch (error) { + if (signal?.aborted) throw error; + const drawing = + error?.timedOut === true || [502, 503, 504].includes(error?.status); + if (!drawing) throw error; + if (attempt >= MASK_ATTEMPTS) + throw new KartaViewSourceError(MASK_SLOW_MESSAGE, { + timedOut: true, + }); + await wait(maskRetryMs, signal); + } + } + }, + /** * Whether a photo file is there: true, false when it is gone (archived * photos answer 409, and a HEAD says 200 regardless), or null when the @@ -335,8 +392,11 @@ export function createKartaViewSource({ } }, - /** Photo records around a point, nearest first. */ - async nearestPhotos({ lat, lon }, { signal } = {}) { + /** + * Photo records around a point, nearest first; with a resolved imagery + * `filter`, only those of its projection uploaded since its cut-off. + */ + async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) { const result = await photoApi( '/2.0/photo/', { @@ -344,6 +404,7 @@ export function createKartaViewSource({ lng: lon.toFixed(7), zoomLevel: String(NEAREST_SEARCH_ZOOM), itemsPerPage: String(NEAREST_LIMIT), + ...photoFilterParams(filter), }, { signal }, ); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 4aed435c7..6dcd393a4 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -6,6 +6,8 @@ import { createLimiter, } from './source.js'; import { + MASK_ATTEMPTS, + MASK_SLOW_MESSAGE, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, @@ -654,3 +656,73 @@ test('an answer turned away unread (an error, a 429, a length past the cap) lets assert.equal(seen.cancelled, true); assert.equal(signals[1].aborted, false, 'a read answer is left alone'); }); + +test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => { + const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const bytesOf = () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => { + let given = false; + return { + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: png }), + cancel: async () => {}, + }; + }, + }, + }); + const urls = []; + let answers = [status(504), status(503), bytesOf()]; + const source = createKartaViewSource({ + maskRetryMs: 0, + fetchImpl: async (url) => { + urls.push(url); + return answers.shift(); + }, + }); + const tile = { x: 13054, y: 8474, z: 14 }; + assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + ]); + // Still drawing after every try: given up, saying so. + urls.length = 0; + answers = Array.from({ length: 10 }, () => status(504)); + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), { + message: MASK_SLOW_MESSAGE, + timedOut: true, + }); + assert.equal(urls.length, MASK_ATTEMPTS); + // A refusal is not asked again. + urls.length = 0; + answers = [status(400)]; + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/); + assert.equal(urls.length, 1); +}); + +test('a filtered nearest search asks KartaView for the projection and upload date', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ + lat: 42.3314, + lon: -83.0458, + filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) }, + }); + await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 }); + assert.equal(urls[0].searchParams.get('projection'), 'SPHERE'); + assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02'); + assert.equal(urls[1].searchParams.has('projection'), false); + assert.equal(urls[1].searchParams.has('startDate'), false); +}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 11680c5d3..6a29b4f69 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; +import { inflateSync } from 'node:zlib'; import { createApplicationStreetLevel } from '../app/layers/streetLevel.js'; import { fixtureTile, @@ -24,10 +25,15 @@ import { answerKartaViewRequest, describeKartaViewCall, KV_LINE, + KV_OLD_STREET, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, } from '../../scripts/fixtures/street-level/kartaView.mjs'; +import { + maskedSegments, + tileSegments, +} from '../layers/streetLevel/providers/kartaview/decode.js'; import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; import { normalizePhoto, @@ -743,3 +749,79 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st 'GET api.kartaview.org/2.0/photo/{id}', ); }); + +/** The alpha of a fixture PNG (8-bit RGBA, unfiltered rows, as it writes them). */ +function fixturePngAlpha(bytes) { + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + const idat = []; + for (let at = 8; at < bytes.length;) { + const length = bytes.readUInt32BE(at); + const type = bytes.toString('ascii', at + 4, at + 8); + if (type === 'IDAT') idat.push(bytes.subarray(at + 8, at + 8 + length)); + at += 12 + length; + } + const rows = inflateSync(Buffer.concat(idat)); + const alpha = new Uint8Array(width * height); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + alpha[y * width + x] = rows[y * (width * 4 + 1) + 1 + x * 4 + 3]; + return { width, height, alpha }; +} + +test('KartaView fixtures filter like KartaView: PNG tiles by view and upload date, the photo search by projection', () => { + const body = (answer) => JSON.parse(answer.body); + const z = 14; + const x = lonToTileX(KV_LINE.lon, z); + const y = latToTileY((KV_LINE.south + KV_LINE.north) / 2, z); + assert.equal(x, lonToTileX(KV_OLD_STREET.lon, z), 'both streets in a tile'); + const base = `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/${z}`; + const segments = tileSegments( + body(answerKartaViewRequest({ method: 'GET', url: `${base}.json` })), + ); + const lons = (kept) => [...new Set(kept.map((segment) => segment[0][0]))]; + const keptFor = (query) => { + const answer = answerKartaViewRequest({ + method: 'GET', + url: `${base}.png?${query}`, + }); + assert.equal(answer.contentType, 'image/png'); + assert.equal(answer.known, true); + return lons( + maskedSegments(segments, fixturePngAlpha(answer.body), { x, y, z }), + ); + }; + assert.deepEqual( + lons(segments).sort(), + [KV_LINE.lon, KV_OLD_STREET.lon].sort(), + ); + assert.deepEqual(keptFor('fieldOfView=360'), [KV_LINE.lon]); + assert.deepEqual(keptFor('fieldOfView=plane').sort(), lons(segments).sort()); + const lastYear = new Date(Date.now() - 365 * 86_400_000) + .toISOString() + .slice(0, 10); + assert.deepEqual(keptFor(`startDate=${lastYear}`), [KV_LINE.lon]); + assert.deepEqual(keptFor('startDate=2099-01-01'), []); + assert.equal( + describeKartaViewCall('GET', new URL(`${base}.png?fieldOfView=360`)), + 'GET api.kartaview.org/2.0/sequence/tiles/{x}/{y}/{z}.png?fieldOfView', + ); + // The photo search: 360° only, and nothing uploaded after the drive. + resetKartaViewFixture(); + const search = (extra) => { + answerKartaViewRequest({ + method: 'GET', + url: 'https://api.kartaview.org/2.0/photo/?lat=0&lng=0', + }); + return body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/?lat=${KV_LINE.south + 0.002}&lng=${KV_LINE.lon}&zoomLevel=18&itemsPerPage=10${extra}`, + }), + ).result.data; + }; + const panos = search('&projection=SPHERE'); + assert.ok(panos.length > 0); + assert.ok(panos.every((record) => record.projection === 'SPHERE')); + assert.deepEqual(search('&startDate=2099-01-01'), []); +}); From c4ecb11f75e3638e11efa6b776761b5e10e7391b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:02:00 +0100 Subject: [PATCH 31/78] feat(street-level): KartaView's SINCE is its upload date, noted in the panel KartaView's server filters by upload date only, so SINCE goes by it throughout for KartaView: the lines (as before), the photo a click opens and the cones, the cut-off taken as its UTC day as KartaView's startDate (passesKartaViewFilter). Providers may declare sinceBasis: 'upload' (validated); while one is lit the panel shows "KartaView: by upload date" under the SINCE slider, read out with it. The gate checks the note, and step 13's picks get 60 s on a loaded machine. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++-- README.md | 2 +- docs/CURRENT-STATE.md | 16 ++++--- scripts/qa-street-level.mjs | 17 +++++-- src/layers/streetLevel/index.js | 1 + .../streetLevel/providers/kartaview/index.js | 11 +++-- .../streetLevel/providers/kartaview/policy.js | 29 ++++++++++-- .../providers/kartaview/policy.test.mjs | 39 ++++++++++++++++ .../providers/kartaview/provider.test.mjs | 46 ++++++++++++------- src/layers/streetLevel/registry.js | 9 ++++ src/layers/streetLevel/registry.test.mjs | 5 ++ src/layers/streetLevel/uiState.js | 14 ++++++ src/layers/streetLevel/uiState.test.mjs | 12 +++++ src/ui/streetLevelControls.js | 4 ++ src/ui/streetLevelControls.test.mjs | 22 +++++++++ src/ui/streetLevelPresentation.js | 1 + src/ui/styles/street-level.css | 11 +++++ src/ui/templates/layer-panels.html | 3 +- 18 files changed, 210 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde9b6883..36c0f2bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,9 +116,11 @@ cone pointing along the drive (KartaView's own compass headings are often 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to KartaView as to Mapillary: its lines are filtered by KartaView's own - server (its filtered PNG tiles, as a mask over the JSON lines; SINCE goes - by upload date there), and the photo a click opens, and the cones, by - each photo's projection and capture date. + server (its filtered PNG tiles, as a mask over the JSON lines), and the + photo a click opens, and the cones, by each photo's projection and upload + date. KartaView's SINCE is the upload date (all its server can filter by); + a note under the slider says so while KartaView is lit (`sinceBasis: + 'upload'` in the provider contract). Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index 9e914764b..f65d5e5d4 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView filters its lines on its own server, by upload date for **SINCE** (never before the capture date, so a line may hold only older captures: the click then says none of its photos pass), and a photo opened is held to its capture date; a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**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), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. 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.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `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 89644fae5..cf29a4bc3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3225,13 +3225,15 @@ until the new ones are in), keeps the JSON segments, and caches masks per filter and tile. A dense filtered PNG can take KartaView over a minute the first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is asked again 4 s later, four tries in all; then the panel says it did not -filter the area in time. KartaView's `startDate` is the upload date, never -before the capture date, so the lines are a superset of what passes; the -nearest lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it -ignores `fieldOfView`) and upload date, then holds each photo to its capture -date (when photos are near but none passes, the panel says so), and the -cones pass the filter on each photo's projection and capture date, as -Mapillary's do. Tiles come uncompressed and +filter the area in time. KartaView's `startDate` is the upload date (its +server filters by nothing else), so SINCE goes by upload date throughout, +the cut-off taken as its UTC day (`passesKartaViewFilter`): the nearest +lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores +`fieldOfView`) and upload date and checks each answer the same way (when +photos are near but none passes, the panel says so), and the cones pass on +each photo's projection and upload date. The provider declares +`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by +upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index e5f76fe8c..c47a3f9f8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,7 +888,7 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); @@ -896,6 +896,14 @@ async function main() { const u = await ui(); assert.equal(u.keyRequired, false); assert.deepEqual(u.filterControls, { pano: true, since: true }); + // KartaView's SINCE is its upload date: the panel says so. + assert.equal(u.sinceNote, 'KartaView: by upload date'); + assert.equal( + await page.$eval('#sl-since-note', (node) => + node.hidden ? null : node.textContent, + ), + 'KartaView: by upload date', + ); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -1613,9 +1621,10 @@ async function main() { ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), ]); // Ground primitives are built over a few frames: poll for a pick. + // A loaded machine renders a frame in seconds: 60 s, not 30. const handle = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:seq:', id: target, @@ -1643,10 +1652,10 @@ async function main() { // The cones are on the globe: one of them picks as an image. Aim at // where each cone billboard is drawn (its ground position at // height 0 can sit a few pixels off, and every missed pick costs a - // render); the same 30 s as the line pick above. + // render); the same 60 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:img:', id: null, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 54076cc90..97b864d40 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -438,6 +438,7 @@ export function createStreetLevelLayer({ color: entry.def.colors.coverage, coverageLabel: entry.def.coverageLabel || null, highlightsSequence: entry.def.highlightsSequence !== false, + sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture', groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index cf932462b..ae05983a4 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,4 +1,3 @@ -import { passesImageryFilter } from '../../filter.js'; import { createLineCoverage } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; @@ -34,6 +33,7 @@ import { assignCourses, cameraBearing, normalizePhoto, + passesKartaViewFilter, tileFilterQuery, } from './policy.js'; @@ -133,6 +133,8 @@ export function createKartaViewProvider({ // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on // its server (see tileFilterQuery), the photos by their own record. filters: Object.freeze(['pano', 'since']), + // SINCE is the upload date for KartaView: the panel notes it. + sinceBasis: 'upload', // Its lines are streets, not sequences: a selected drive shows as cones. highlightsSequence: false, coverageLabel: Object.freeze({ @@ -417,6 +419,7 @@ export function createKartaViewProvider({ lat: photo.lat, compassAngle: cameraBearing(photo) ?? 0, capturedAt: photo.capturedAt ?? 0, + uploadedAt: photo.uploadedAt, isPano: photo.isPano, })), ); @@ -425,7 +428,8 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', - // The imagery filter applies to cones as to Mapillary's (capture date). + // By projection and upload date, as the lines. + passes: passesKartaViewFilter, }); /** Sequence id → its photo count from KartaView (asked once). */ @@ -621,8 +625,7 @@ export function createKartaViewProvider({ if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); if (metres > NEAREST_RADIUS_M) continue; - // KartaView filtered by upload date: hold it to the capture date. - if (!passesImageryFilter(photo, filter)) filteredOut++; + if (!passesKartaViewFilter(photo, filter)) filteredOut++; else near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index d77353d6c..8d2c955ae 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -117,9 +117,9 @@ export const KARTAVIEW_CREDIT_HTML = * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is * kept where it lies on the mask's drawn pixels. * - * KartaView's date filter is the upload date, never before the capture - * date: the lines are the streets with photos uploaded since the cut-off, - * and a photo opened from them is held to its capture date. + * KartaView's date filter is the upload date, and SINCE goes by it + * throughout (lines, the photo a click opens, the cones), as KartaView's own + * map does; the panel says so under the slider. */ export const MASK_TIMEOUT_MS = 20_000; /** @@ -139,6 +139,8 @@ export const MASK_SLOW_MESSAGE = 'KartaView did not filter this area in time — move the map to ask again'; export const FILTERED_OUT_MESSAGE = 'None of KartaView’s photos here pass the imagery filter'; +/** Under the SINCE slider while KartaView is lit. */ +export const SINCE_NOTE = 'KartaView: by upload date'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { @@ -148,6 +150,23 @@ function utcDay(ms) { const filtersSince = (filter) => Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; +const DAY_MS = 86_400_000; + +/** + * Whether a photo passes the resolved imagery filter by its projection and + * upload date, the cut-off taken as its UTC day, as KartaView's startDate. + * @param {{isPano: boolean, uploadedAt: number|null}} photo + * @param {{pano: string, sinceMs: number|null}|null} filter + */ +export function passesKartaViewFilter(photo, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !photo.isPano) return false; + if (filter.pano === 'flat' && photo.isPano) return false; + if (!filtersSince(filter)) return true; + const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS; + return (photo.uploadedAt ?? 0) >= day; +} + /** * The query of a filtered PNG coverage tile, or '' when nothing is * filtered. The day, not the instant, so it is stable for a day (and keys @@ -164,7 +183,7 @@ export function tileFilterQuery(filter) { /** * The photo search's own filter: projection and upload date (its - * `fieldOfView` is ignored). The capture date is checked on the answers. + * `fieldOfView` is ignored). * @param {{pano: string, sinceMs: number|null}|null} filter resolved * @returns {Record} */ @@ -349,6 +368,8 @@ export function normalizePhoto(record) { isPano, capturedAt: kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + // KartaView's SINCE goes by this (its server filters by upload date). + uploadedAt: kartaViewTimeMs(record.dateAdded), urls: { // A 1280 px rendition; for a 360° photo it is the front view only. view: diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index fe23b8f48..82a68555e 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -7,6 +7,7 @@ import { kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, + passesKartaViewFilter, photoFilterParams, resizedImageUrl, tileFilterQuery, @@ -307,3 +308,41 @@ test('the imagery filter becomes KartaView’s tile query and photo-search param projection: 'PLANE', }); }); + +test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => { + const photo = normalizePhoto( + record({ + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2026-03-05 00:10:00', + }), + ); + assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10)); + const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) }); + // Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's + // startDate=2026-03-05 keeps it. + assert.equal( + passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')), + true, + ); + assert.equal( + passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }), + true, + ); + assert.equal(passesKartaViewFilter(photo, null), true); + assert.equal( + passesKartaViewFilter( + { ...photo, uploadedAt: null }, + since('2020-01-01T00:00:00Z'), + ), + false, + 'no upload date passes no SINCE', + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 013a510f7..706988904 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -148,6 +148,11 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); assert.deepEqual(definition.filters, ['pano', 'since']); + assert.equal( + definition.sinceBasis, + 'upload', + 'SINCE is KartaView’s upload date', + ); assert.equal( definition.highlightsSequence, false, @@ -209,36 +214,44 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing ); }); -test('the nearest photo passes the imagery filter by its capture date; none passing says so', async () => { +test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => { const since = Date.now() - 86_400_000; const filter = { pano: 'pano', sinceMs: since }; - // KartaView filters the search by upload date: an old capture uploaded - // today still comes back, and is held to its capture date here. - const pano = (id, metres, shot) => ({ + const pano = (id, metres, uploaded) => ({ ...photoRecord(id, { metres }), projection: 'SPHERE', fieldOfView: '360', - shotDate: shot, + shotDate: '2016-08-23 20:17:47.000', + dateAdded: uploaded, }); + // Both taken in 2016; only the second was uploaded since yesterday. const { instance, source } = setup( { nearest: [ - pano('old', 5, '2016-08-23 20:17:47.000'), - pano('new', 20, shotAgo(3_600_000)), + pano('stale', 5, '2016-08-24 04:01:07'), + pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)), ], }, { filter }, ); const at = { lat: 42.3314, lon: -83.0458 }; - assert.equal(await instance.nearestImage(at), 'new'); + assert.equal(await instance.nearestImage(at), 'fresh'); assert.deepEqual(source.calls.nearestFilters, [filter]); - const old = setup( - { nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] }, + const stale = setup( + { nearest: [pano('stale', 5, '2016-08-24 04:01:07')] }, { filter }, ); - await assert.rejects(old.instance.nearestImage(at), { + await assert.rejects(stale.instance.nearestImage(at), { message: FILTERED_OUT_MESSAGE, }); + // Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps + // it, and so does the check. + const day = new Date(since).toISOString().slice(0, 10); + const sameDay = setup( + { nearest: [pano('early', 5, `${day} 00:00:01`)] }, + { filter }, + ); + assert.equal(await sameDay.instance.nearestImage(at), 'early'); }); test('a cone opens its photo; a line opens the photo nearest the click', () => { @@ -663,21 +676,22 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from', ); }); -test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => { +test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => { const env = await mounted( { sequences: { s: [0, 1, 2, 3] } }, { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, ); - // 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016. + // 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all); + // 2 and 3: flat ones uploaded in 2016. const shaped = (record) => Number(record.sequenceIndex) < 2 ? { ...record, projection: 'SPHERE', fieldOfView: '360', - shotDate: shotAgo(3_600_000), + dateAdded: shotAgo(3_600_000).slice(0, 19), } - : record; + : { ...record, dateAdded: '2016-08-24 04:01:07' }; const getPhoto = env.source.getPhoto; env.source.getPhoto = async (id) => shaped(await getPhoto(id)); const getPage = env.source.getSequencePhotos; @@ -690,7 +704,7 @@ test('the imagery filter thins the cones by each photo’s capture date and proj await env.instance.selectSequence('s'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); - assert.equal(cones.count(), 2, 'the recent 360° photos'); + assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday'); assert.deepEqual(cones(), [0, 1]); env.actions.filter = { pano: 'flat', sinceMs: null }; env.instance.setFilter(); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 68fff8444..3451447e4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -24,6 +24,7 @@ export function filtersOf(provider) { * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -160,6 +161,14 @@ export function validateProviders(providers) { throw new TypeError( `Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`, ); + if ( + provider.sinceBasis !== undefined && + provider.sinceBasis !== 'capture' && + provider.sinceBasis !== 'upload' + ) + throw new TypeError( + `Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`, + ); ids.add(provider.id); prefixes.push(provider.pickPrefix); } diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 7981d9acf..1783d7f42 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker', () => validateProviders([def({ marker: { kind: 'pegman' } })]), /marker must be/, ); + validateProviders([def({ sinceBasis: 'upload' })]); + assert.throws( + () => validateProviders([def({ sinceBasis: 'shot' })]), + /sinceBasis must be 'capture' or 'upload'/, + ); }); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index cab0d12b1..4c4590b3e 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -19,6 +19,7 @@ import { COLORS } from './policy.js'; * @property {Array<'pano'|'since'>} [filters] * @property {{noun: string, action: string}|null} [coverageLabel] * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. + * @property {'capture'|'upload'} [sinceBasis] What its SINCE compares. */ /** @@ -72,6 +73,18 @@ export function filterControls(providers) { return { pano: honoured('pano'), since: honoured('since') }; } +/** + * The note under SINCE when a lit provider dates its imagery by upload, + * e.g. "KartaView: by upload date", or null. + * @param {Array} providers + */ +export function sinceNote(providers) { + const names = providers + .filter((p) => p.on && p.sinceBasis === 'upload') + .map((p) => p.name); + return names.length ? `${names.join(', ')}: by upload date` : null; +} + /** * The panel snapshot from core state and provider snapshots. The legend has * one swatch per active source that draws coverage lines, then Selected. @@ -112,6 +125,7 @@ export function composeUIState({ keyRejected, filter: { ...filter }, filterControls: filterControls(providers), + sinceNote: sinceNote(providers), providers: providers.map((p) => ({ ...p })), coverage, legend, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 813213c4a..21b5eefe0 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); +test('a lit provider that dates by upload puts a note under SINCE', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + sinceBasis: 'upload', + }); + const note = (providers) => composeUIState({ ...base, providers }).sinceNote; + assert.equal(note([provider()]), null, 'Mapillary: capture date'); + assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); +}); + test('hint and error come from the first active provider that has one', () => { const ui = composeUIState({ ...base, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 7e6759199..db1a626f5 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -73,6 +73,7 @@ export class StreetLevelControls { sinceRow: byId('sl-since-row'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), + sinceNote: byId('sl-since-note'), legend: byId('sl-legend'), followBtn: byId('sl-follow-btn'), viewerWrap: byId('sl-viewer-wrap'), @@ -527,6 +528,9 @@ export class StreetLevelControls { const shows = view.filterControls || { pano: true, since: true }; setProp(el.panoRow, 'hidden', !shows.pano); setProp(el.sinceRow, 'hidden', !shows.since); + // A lit source that dates by upload says so under the slider. + setProp(el.sinceNote, 'textContent', view.sinceNote || ''); + setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); setProp(el.legend, 'hidden', view.legend.length === 0); this._renderRadios( diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index a27afd468..eb572781b 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import test from 'node:test'; import { StreetLevelControls } from './streetLevelControls.js'; import { createStreetLevelLayer } from '../layers/streetLevel/index.js'; @@ -313,6 +314,7 @@ function panelDom() { const sinceRow = add(controls, 'div', { id: 'sl-since-row' }); add(sinceRow, 'input', { id: 'sl-since' }); add(sinceRow, 'output', { id: 'sl-since-label' }); + add(sinceRow, 'small', { id: 'sl-since-note' }); add(settings, 'ul', { id: 'sl-legend' }); const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); add(metaRow, 'div', { id: 'sl-coverage-meta' }); @@ -1224,6 +1226,26 @@ test('a swap names every registered provider, also those hidden on this map', () ]); })); +test('a note under SINCE says what date it compares, only while one is given', () => + withDom(async (dom) => { + const state = uiState(); + const { layer } = stubPanel(dom, state); + const note = () => dom.root.querySelector('#sl-since-note'); + assert.equal(note().hidden, true, 'none for Mapillary'); + layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + assert.equal(note().hidden, false); + assert.equal(note().textContent, 'KartaView: by upload date'); + layer.publish(state); + assert.equal(note().hidden, true); + // The panel's own markup has it, read out with the slider. + const template = fs.readFileSync( + new URL('./templates/layer-panels.html', import.meta.url), + 'utf8', + ); + assert.match(template, /]* hidden>/); + assert.match(template, /aria-describedby="sl-since-label sl-since-note"/); + })); + test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () => withDom(async (dom) => { const state = uiState(); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 9ecfd077e..616829095 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -224,6 +224,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { since: state.filterControls?.since !== false, }, since: presentSince(filter.sinceDays, now), + sinceNote: state.sinceNote || null, legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 0e7258978..324f28bb6 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -299,6 +299,17 @@ button.sl-status:focus-visible { outline-offset: 3px; } +.sl-since-note { + grid-column: 2; + font-family: var(--font-mono); + font-size: var(--sl-text-small); + color: var(--text-secondary); +} + +.sl-since-note[hidden] { + display: none; +} + .sl-since-label { grid-column: 2; font-family: var(--font-mono); diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4a44c03eb..d079ebd11 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -128,8 +128,9 @@
- + ANY DATE +
    From 4838a643730353af0f9d810d522ab393766c0fce Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 17:13:17 +0100 Subject: [PATCH 32/78] docs: restore NYC CCTV changelog entry below the Bing note The merge from main replaced the NYC DOT opt-in pack entry instead of adding the Bing entry above it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 775566c12..7acdedf6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ their ion-backed providers, retaining ion and per-tile attribution without widening the Content Security Policy. +- Add New York City traffic cameras from the NYC DOT Traffic Management Center + as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the + camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an + operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's + requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to + `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each + camera UUID on that same host, so a catalog field cannot steer the proxy + off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest + the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. From 6f0d1bb35f121ec69b64d136901e60416a339a05 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:07:18 -0700 Subject: [PATCH 33/78] fix(ui): keep panels the right rail hides itself in its layout Exclusive presentation hides collapsed right-rail panels with display: none. Skipping every display: none panel also skipped those, so panels the rail had auto-collapsed stayed collapsed after the HUD was hidden or left Tactical. Only panels hidden by other CSS are skipped now. Adds the changelog and current-state notes. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 3 +++ src/ui/panelRails.test.mjs | 26 ++++++++++++++++++++++++++ src/ui/rightPanelRail.js | 9 +++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..7c7ada1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- The right rail ignores panels that other CSS hides, such as during an event + view, so they no longer force one-panel-at-a-time layout or collapse visible + panels. Panels the rail collapses itself are still restored. + - Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through their ion-backed providers, retaining ion and per-tile attribution without widening the Content Security Policy. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..59ba91202 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1047,6 +1047,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. +The right rail skips panels hidden by other CSS (computed `display: none`), but +keeps the collapsed panels its own exclusive presentation hides, so automatic +collapse can still be undone. ## Surface keyboard lifecycle diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index de5b3997b..7015c89e2 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -723,6 +723,32 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity ); }); +test('right layout still restores panels that exclusive presentation hides', () => { + const f = fixture('right'); + // Model layers.css: an exclusive rail hides its collapsed panels. + f.options.windowRef.getComputedStyle = (node) => ({ + ...node.computed, + display: + f.stack.classList.contains('layout-exclusive') && + node.classList.contains('collapsed') + ? 'none' + : node.computed.display, + }); + f.expand(f.first, 250); + f.second.classList.add('layout-auto-collapsed'); + f.stack.classList.add('layout-exclusive'); + + f.run(); + assert.equal(f.stack.classList.contains('layout-exclusive'), true); + assert.equal(f.second.getAttribute('aria-hidden'), 'true'); + + f.options.hud.visible = false; + f.run(); + assert.equal(f.second.classList.contains('collapsed'), false); + assert.equal(f.second.classList.contains('layout-auto-collapsed'), false); + assert.deepEqual(f.collapsed, ['second']); +}); + test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index b62be511c..be49999fd 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,12 +69,17 @@ 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. + // A floating (lifted-out) panel is not laid out by the rail. Neither is one + // that other CSS hides, such as an event view. Exclusive presentation hides + // collapsed panels itself; those stay in the rail so they can be restored. + const hiddenByRail = (panel) => + stack.classList.contains('layout-exclusive') && + panel.classList.contains('collapsed'); const panels = [...stack.children].filter( (panel) => panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden && - getComputedStyle(panel).display !== 'none', + (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => From 632005d221f2a5c60045769208c4b442d69cf3e5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:27 -0400 Subject: [PATCH 34/78] fix(director): seek camera longitude across the shortest dateline arc --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/director/director-dateline-seek.test.mjs | 26 ++++++++++++++++++++ src/director/timeline.js | 8 +++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/director/director-dateline-seek.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..2075effa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Director timeline seeks cross the dateline along the short longitude arc. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..cb0b43c54 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): seek camera longitude across the shortest dateline arc + +Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs new file mode 100644 index 000000000..f950814f3 --- /dev/null +++ b/src/director/director-dateline-seek.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cameraAtProgress } from './timeline.js'; +const camera = (lon) => ({ + lat: 10, + lon, + alt: 1000, + heading: 0, + pitch: -40, + roll: 0, +}); +test('director seek follows the short dateline arc in both directions', () => { + for (const [a, b] of [ + [179, -179], + [-179, 179], + ]) { + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179); + } + assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index 7fd4cbbae..1da08d498 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -45,7 +45,13 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { }; return { lat: lerp(source.lat, target.lat), - lon: lerp(source.lon, target.lon), + lon: + t === 0 + ? Number(source.lon) + : t === 1 + ? Number(target.lon) + : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - + 180, alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 3b08bd74872a4a7c2dac103139a05ce660fd71f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:42 -0400 Subject: [PATCH 35/78] fix: address review feedback for PR #1023 --- docs/CURRENT-STATE.md | 6 +----- src/director/camera.js | 7 ++++++- src/director/timeline.js | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb0b43c54..37a9e6c0a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,6 +399,7 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. +Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -5310,8 +5311,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): seek camera longitude across the shortest dateline arc - -Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/camera.js b/src/director/camera.js index 059c7a873..e009c2cba 100644 --- a/src/director/camera.js +++ b/src/director/camera.js @@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) { }; } +/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */ +export function normalizeCameraLongitude(longitude) { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + /** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */ export function sampleCameraMove(move, progress) { const t = Math.max(0, Math.min(1, Number(progress) || 0)); @@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) { const { from, to } = move; return { lat: lerp(from.lat, to.lat), - lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180, + lon: normalizeCameraLongitude(angle(from.lon, to.lon)), alt: lerp(from.alt, to.alt), heading: angle(from.heading, to.heading), pitch: lerp(from.pitch, to.pitch), diff --git a/src/director/timeline.js b/src/director/timeline.js index 1da08d498..fbaca4301 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -2,6 +2,7 @@ import { resolveCameraPose, resolveCameraMove, sampleCameraMove, + normalizeCameraLongitude, } from './camera.js'; const DEFAULT_SHOT_DURATION_SEC = 4; @@ -50,8 +51,7 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { ? Number(source.lon) : t === 1 ? Number(target.lon) - : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - - 180, + : normalizeCameraLongitude(lerpAngle(source.lon, target.lon)), alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 2c5fb039ce1da3b0ce7ba9e177499b16c14c4656 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:43 -0400 Subject: [PATCH 36/78] fix(tools): enforce string schema lengths by Unicode code point --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/tools/schema.js | 5 +++-- src/tools/tool-schema-codepoints.test.mjs | 20 ++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/tools/tool-schema-codepoints.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..f7607fd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..7e1970768 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): enforce string schema lengths by Unicode code point + +Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index 7c40c3eb5..cdb63cfc1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,9 +106,10 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - if (schema.minLength != null && value.length < schema.minLength) + const length = [...value].length; + if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && value.length > schema.maxLength) + if (schema.maxLength != null && length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); if ( schema.pattern !== undefined && diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs new file mode 100644 index 000000000..c379b77e9 --- /dev/null +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateValue } from './schema.js'; +test('tool string schemas count Unicode characters rather than UTF-16 units', () => { + const value = '𠮷野家😀'; + assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []); + assert.equal( + validateValue({ type: 'string', minLength: 5 }, value).length, + 1, + ); + assert.equal( + validateValue({ type: 'string', maxLength: 3 }, value).length, + 1, + ); + assert.deepEqual( + validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'), + [], + ); + assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); +}); From 615ba9a00826a50c2907184b9193c6e6d51bdc0b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:53 -0400 Subject: [PATCH 37/78] fix: address review feedback for PR #1026 --- docs/CURRENT-STATE.md | 7 ++----- src/tools/schema.js | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e1970768..e66800fd5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -73,6 +73,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). ## Tools and local MCP server — October 1, 2026 +Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): enforce string schema lengths by Unicode code point - -Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index cdb63cfc1..b6e8e2fd0 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,7 +106,8 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - const length = [...value].length; + let length = 0; + for (const character of value) length += 1; if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && length > schema.maxLength) From c86c1a4ccedb0468bc622c00ee151c1224bab71a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:35:40 -0400 Subject: [PATCH 38/78] fix(weather): keep absent observations unknown instead of reporting clear skies --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/regionalModel.js | 23 +++++++++++++++++-- .../weather-missing-observations.test.mjs | 20 ++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/data/weather-missing-observations.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..d3a8f9027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..c066ab182 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(weather): keep absent observations unknown instead of reporting clear skies + +Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..90eeac1fd 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -90,9 +90,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; + if ( + !current || + current.temperature_2m == null || + typeof current.temperature_2m === 'boolean' || + (typeof current.temperature_2m === 'string' && + !current.temperature_2m.trim()) || + !Number.isFinite(Number(current.temperature_2m)) + ) + return null; const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; return Number.isFinite(Number(value)) ? Number(value) : null; }; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, @@ -119,6 +132,12 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { + if ( + code == null || + typeof code === 'boolean' || + (typeof code === 'string' && !code.trim()) + ) + return 'CONDITIONS UNKNOWN'; const value = Number(code); if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; diff --git a/src/data/weather-missing-observations.test.mjs b/src/data/weather-missing-observations.test.mjs new file mode 100644 index 000000000..4e89dcedd --- /dev/null +++ b/src/data/weather-missing-observations.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js'; +test('missing weather observations never assert clear conditions', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN'); + assert.equal( + normalizeRegionalWeather({ current: { temperature_2m: value } }), + null, + ); + } + assert.equal(weatherCodeLabel(0), 'CLEAR'); + assert.equal(weatherCodeLabel('0'), 'CLEAR'); + const record = normalizeRegionalWeather({ + current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false }, + }); + assert.equal(record.temperatureC, 0); + assert.equal(record.weatherCode, null); + assert.equal(record.windKph, null); +}); From 7126555acb42d8a2c9bb7e60a14fc569a628bccf Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:54 -0400 Subject: [PATCH 39/78] fix: address review feedback for PR #1027 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c066ab182..076603256 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4625,6 +4625,8 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. + - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(weather): keep absent observations unknown instead of reporting clear skies - -Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. From a140c52f847b4b3cf7f9bfe1b6b4597afd25747f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:36:07 -0400 Subject: [PATCH 40/78] fix(directions): report truncation only when a maneuver was actually omitted --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/data/route-cap-truthfulness.test.mjs | 24 ++++++++++++++++++++++++ src/data/routeSteps.js | 2 +- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/data/route-cap-truthfulness.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..e1518ce6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d6222d72e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(directions): report truncation only when a maneuver was actually omitted + +Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. diff --git a/src/data/route-cap-truthfulness.test.mjs b/src/data/route-cap-truthfulness.test.mjs new file mode 100644 index 000000000..7d5d80153 --- /dev/null +++ b/src/data/route-cap-truthfulness.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js'; +const step = (type) => ({ + maneuver: { type, location: [10, 20] }, + distance: 10, + duration: 1, +}); +test('exact-cap routes remain complete and boundary exits retain their distance', () => { + const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn')); + assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false); + assert.equal( + normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] }) + .truncated, + true, + ); + steps[steps.length - 1] = step('roundabout'); + const result = normalizeOsrmSteps({ + legs: [{ steps: [...steps, step('exit roundabout')] }], + }); + assert.equal(result.truncated, false); + assert.equal(result.steps.length, ROUTE_STEPS_MAX); + assert.equal(result.steps.at(-1).distanceM, 20); +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index 5ee11602a..e3144bdf7 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) { } continue; } + if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; step.instruction = instructionFor(step); out.push(step); - if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true }; } } return { steps: out, truncated: false }; From 69815a92bec2f3ceaf41aecf919b6f78edc8818a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 41/78] docs: address review scope and feature placement for #1030 --- docs/CURRENT-STATE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6222d72e..607449cf8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3317,6 +3317,10 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. +Directions retains up to 200 turn instructions. Its truncation notice appears +only when another independent instruction is omitted; a complete route with +exactly 200 turns does not show the notice. + 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 profile; SET A and SET B arm the next globe click (Escape or a second press @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(directions): report truncation only when a maneuver was actually omitted - -Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. From ba58a5ec50286ce4b06ac483b2eff9ddcc12aa91 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:05 -0400 Subject: [PATCH 42/78] fix(street-level): deduplicate overlapping dateline tile halves --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ .../street-level-unique-tiles.test.mjs | 15 +++++++++++++++ src/layers/streetLevel/tileMath.js | 7 ++++++- 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/layers/streetLevel/street-level-unique-tiles.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..0814f7e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d0014227f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(street-level): deduplicate overlapping dateline tile halves + +Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs new file mode 100644 index 000000000..f96f0b892 --- /dev/null +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tilesForBbox } from './tileMath.js'; +test('coarse dateline coverage counts each tile exactly once', () => { + for (const z of [0, 1, 2]) { + const result = tilesForBbox([170, -1, -170, 1], z); + const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); + assert.equal(new Set(keys).size, keys.length); + assert.equal(result.total, keys.length); + const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); + assert.equal(limited.total, keys.length); + assert.equal(limited.truncated, keys.length > 1); + assert.equal(limited.tiles.length, 1); + } +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..ca7b09cd5 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -147,7 +147,12 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; // Each tile's distance is computed once; ties keep their row order. - const ranked = tiles + const unique = [ + ...new Map( + tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), + ).values(), + ]; + const ranked = unique .map((tile) => ({ tile, d: distance2(tile) })) .sort((a, b) => a.d - b.d) .map(({ tile }) => tile); From f67888be00327c45bb2f0a651979ea9eb77489f4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:01 -0400 Subject: [PATCH 43/78] docs: address review scope and feature placement for #1032 --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 9 +++--- .../street-level-unique-tiles.test.mjs | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814f7e7c..1d76a1963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0014227f..c2291829e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3122,6 +3122,10 @@ 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. +Mapillary coverage ranks unique tile addresses before applying the 16-tile +overview budget. Overlapping dateline halves in a polar view therefore do not +waste overview slots on the same tile; loading already skips duplicate keys. + On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out above 1,800 m) the surface mode switches from `draped` to `terrain`: draped lines land on roofs and tree tops, so `groundCast.js` instead places lines, @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(street-level): deduplicate overlapping dateline tile halves - -Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index f96f0b892..224de523b 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -1,15 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { tilesForBbox } from './tileMath.js'; -test('coarse dateline coverage counts each tile exactly once', () => { - for (const z of [0, 1, 2]) { - const result = tilesForBbox([170, -1, -170, 1], z); - const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); - assert.equal(new Set(keys).size, keys.length); - assert.equal(result.total, keys.length); - const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); - assert.equal(limited.total, keys.length); - assert.equal(limited.truncated, keys.length > 1); - assert.equal(limited.tiles.length, 1); - } + +test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => { + // coverage uses the actual view bbox above zoom 1; polar views can wrap. + const bbox = [-10, 60, -20, 85]; + const result = tilesForBbox(bbox, 3); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(keys.length, 24); + assert.equal(new Set(keys).size, 24); + assert.equal(result.total, 24); + assert.equal(result.truncated, false); + const overview = tilesForBbox(bbox, 3, { limit: 16 }); + assert.equal(overview.total, 24); + assert.equal(overview.tiles.length, 16); + assert.equal( + new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size, + 16, + ); + assert.equal(overview.truncated, true); }); From 750f3da6eba3b115399ab50d8fadb9144fb7a846 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:48:51 +0900 Subject: [PATCH 44/78] fix(styles): keep Snow wind sway bounded over time (#782) The horizontal offset multiplied elapsed time by a sine, so flakes swung faster the longer the app ran. It is now the integral of the intended sway velocity, which stays bounded at any session age; Wind still scales the amplitude. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 2 ++ src/styles/snow.js | 6 ++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a7f6823..1ef811a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Snow flakes sway within a fixed range instead of drifting sideways faster the + longer the app runs; the Wind setting still scales the sway. + - The right rail ignores panels that other CSS hides, such as during an event view, so they no longer force one-panel-at-a-time layout or collapse visible panels. Panels the rail collapses itself are still restored. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9281f4a40..ebf7495f5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1013,6 +1013,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has no DOM dependency. Existing presets and the 500 ms transition remain unchanged. Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. +Snow's sideways sway stays within a fixed range at any session age; Wind scales +its amplitude. ## Display control ownership diff --git a/src/styles/snow.js b/src/styles/snow.js index e2ada6ad7..b23f9bd71 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -29,11 +29,13 @@ export const snowShader = { float depth = 0.5 + layer * 0.5; float speed = 0.4 + layer * 0.3; float size = mix(0.01, 0.025, layer); - float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2); + float windForce = 0.05 + wind * 0.2; vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0); snowUV.y += time * speed; - snowUV.x += time * windForce; + // Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce, + // so the sway stays bounded however long the app has been running. + snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce; vec2 cell = floor(snowUV); vec2 f = fract(snowUV); From 85754bcf50c7b52fe3c753d34c653b4ae6826960 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:14 -0700 Subject: [PATCH 45/78] test: say which CI job and which files the discovery guard covers --- scripts/run-unit-tests.mjs | 9 +++++---- src/unitTestRunner.test.mjs | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 7024ea38c..6cc6aaa94 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -25,10 +25,11 @@ export function assertNode24AllocationRuntime(version = process.versions.node) { * Directories this runner searches for `*.test.mjs`, in stable order. * * `server/` belongs here because provider modules own tests of their own: a - * suite placed beside the module it covers must run, and `npm test` is the only - * thing CI invokes, so a directory missing from this list is a suite that never - * runs anywhere. `unitTestRunner.test.mjs` asserts the list covers every test - * file in the repository, so adding a tree without adding it here fails. + * suite placed beside the module it covers must run, and CI's unit-test job runs + * `npm test`, so a directory missing from this list is a suite that never runs + * there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs` + * outside dependencies, build output and dot-directories, so adding a tree + * without adding it here fails. */ export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']); diff --git a/src/unitTestRunner.test.mjs b/src/unitTestRunner.test.mjs index 201f6ed8e..80d9091e0 100644 --- a/src/unitTestRunner.test.mjs +++ b/src/unitTestRunner.test.mjs @@ -64,11 +64,12 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () = }); test('the runner discovers every test file in the repository', () => { - // `npm test` is the only suite CI runs, and it runs exactly what this - // function returns. A `*.test.mjs` outside the searched roots therefore never - // executes anywhere, while still reading — in review and to its author — like - // covered code. Compare the two sets rather than their sizes, so the failure - // names the files instead of only disagreeing about a count. + // CI's unit-test job runs `npm test`, which runs exactly what this function + // returns. A `*.test.mjs` outside the searched roots therefore never runs + // there, while still reading — in review and to its author — like covered + // code. Compare the two sets rather than their sizes, so the failure names the + // files instead of only disagreeing about a count. The final equality also + // catches the reverse: files the runner finds that this walk skips. const discovered = discoverUnitTestFiles(repositoryRoot); const onDisk = testFilesOnDisk(repositoryRoot); const undiscovered = onDisk.filter((file) => !discovered.includes(file)); From afb3a92914b4d1bc19fa62d708456a331e0db274 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:59:51 -0700 Subject: [PATCH 46/78] docs: say what the connection reads from the voice credential It reads the token, model, transcription model and tier for diagnostics and cost accounting, so the credential is not opaque to it. Also lists requestToken's authMode option in APPLICATION.md. --- docs/APPLICATION.md | 2 +- docs/VOICE-OWNERSHIP.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index deafe9168..c5a2642e6 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints. Voice controls accept an action runner and a controller factory through `createVoiceCommands` (`./voice/commands`). The default Realtime controller owns microphone tracks, playback, push-to-talk, tool cancellation and radio handoff. -Its backend supplies `requestToken({ tier, signal })` and +Its backend supplies `requestToken({ tier, authMode, signal })` and `negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter (`./voice/realtime-backend`) accepts separate token and connection transports and endpoints. Only the short-lived client secret reaches the SDP endpoint. diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index 98b6b0c5c..bb28eb3e9 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -26,8 +26,8 @@ The backend is replaceable per deployment: `realtimeController.js` accepts it as `backend` option and falls back to `createRealtimeBackend()`. Its two method signatures live with [application construction](APPLICATION.md); what belongs here is the ownership. The connection is the backend's only consumer in the application. -The resolved credential is opaque to the connection and is handed to `negotiate` -whole. Client-secret expiry is refused inside the backend, not by the connection. +The connection reads credential metadata for diagnostics and cost accounting, +then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection. `realtimeOwners.test.mjs` drives whole sessions through a substitute that implements only those two methods. From 4cd7200fe445200968109b2a65cb6a98a485deb3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 12:00:16 -0700 Subject: [PATCH 47/78] docs: attribute the Pinokio stutter report accurately The workaround came from one Pinokio 8.2.0 user who did not state an OS, and a single report suggests rather than isolates the embedded-browser cause. --- README.md | 10 +++++----- docs/PERFORMANCE.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6c6b1c238..50c5a4003 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,11 @@ Version 8.2 fixes the launcher installation issue; **Pinokio opens but the globe is very choppy?** Before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by -Pinokio and open the same URL in your normal hardware-accelerated browser. A -Pinokio 8.2.0 Linux report reproduced severe stutter in Pinokio's embedded view -while the same running server was smooth in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). -That isolates the browser/compositor path from the app server and your machine; -it does not establish a minimum GPU requirement. +Pinokio and open the same URL in your normal hardware-accelerated browser. One +Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went +away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). +That points to the embedded browser rather than the app server or your +hardware; it does not establish a minimum GPU requirement. ### Path 2 — Terminal / coding agent diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 873ef8768..65d0abae9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -129,11 +129,11 @@ Use the same controls before attributing a difference to the application: 7. Treat a live-source outage as missing coverage, not as evidence of low client rendering cost. 8. Record whether the app is running in Pinokio's embedded view or in an - external browser. The same Pinokio 8.2.0 Linux server was reported as - severely stuttery in the embedded view and smooth when its local URL was - opened directly in a normal browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). - Treat that as a browser/compositor-path difference until the GPU renderer - proves otherwise. + external browser. One Pinokio 8.2.0 user reported severe stutter in the + embedded view that went away when the same local URL was opened in a normal + browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)). + Do not compare embedded-view and external-browser measurements directly; + compare the reported GPU renderer and matched scene settings first. ## What is not established yet From 4adcb3f7274951bd5c763cc59e190ef559c169de Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:17 -0400 Subject: [PATCH 48/78] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d76a1963..69748384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. - - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. From 91ac5c119084fdb200362b2c6dd07cb2324fb081 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:23:49 -0700 Subject: [PATCH 49/78] test(codex): match the installed-executable stub on Windows (#997) The test's access stub matched a forward-slash suffix, so on Windows the resolver's backslash path never matched and it fell back to PATH. The stub and the expected value now use the same path.join result. The Windows CI job also runs this test file now. Co-authored-by: Sameh Khamis --- .github/workflows/ci.yml | 2 +- src/codexOauthRealtime.test.mjs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..491f2468d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: run: npm run check:boundaries - name: Run focused onboarding tests - run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs + run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs - name: Build production bundle run: npm run build diff --git a/src/codexOauthRealtime.test.mjs b/src/codexOauthRealtime.test.mjs index 07e1a7dcc..4a577ac70 100644 --- a/src/codexOauthRealtime.test.mjs +++ b/src/codexOauthRealtime.test.mjs @@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => { const checked = []; + // Built with path.join so the stub matches the resolver on Windows too, + // where the separators are backslashes. + const installed = path.join('/home/fixture', '.local', 'bin', 'codex'); assert.equal( resolveCodexExecutable({ environment: {}, home: '/home/fixture', access(candidate) { checked.push(candidate); - if (candidate.endsWith('/.local/bin/codex')) return; + if (candidate === installed) return; throw new Error('missing'); }, }), - path.join('/home/fixture', '.local', 'bin', 'codex'), + installed, ); assert.equal(checked.length, 1); From 8daaa065d2b410380e18a5702112d7cad7e2e900 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 14:16:30 -0700 Subject: [PATCH 50/78] qa: log the Recent Imagery gate's stability mode and document its flag --- docs/CURRENT-STATE.md | 2 +- scripts/qa-recent-imagery.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ebf7495f5..91ba81591 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4451,7 +4451,7 @@ are omitted rather than framing the wrong part of the globe. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. - **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`. - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). -- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check. +- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples. ### 3D Aircraft + Tracking (June 2026) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 9c1d6700e..5d42279dc 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -383,6 +383,9 @@ try { const initialStack = await page.evaluate(() => window.__riQa.activeStack()); const photoreal = initialStack === 'photoreal'; console.log(`initial map stack: ${initialStack}`); + console.log( + `stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`, + ); const onPhotorealOnly = (name, passed, detail) => photoreal ? check(name, passed, detail) From 4cacd4cc0021858c009d38a977095fac73a49136 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:53 -0700 Subject: [PATCH 51/78] fix(qa): exit QA gates gracefully on Windows (#1064) process.exit(2) after a successful fetch trips a libuv assertion on Windows and turns the exit code into 127. The four gates set process.exitCode and return instead, and discard the unread reachability response so an early exit does not wait for the server to finish the body. Co-authored-by: Sameh Khamis --- scripts/qa-firms.mjs | 8 ++++++-- scripts/qa-traffic-jamviz-ab.mjs | 8 ++++++-- scripts/qa-traffic-preset-ab.mjs | 8 ++++++-- scripts/qa-traffic.mjs | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..27c8663bf 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -230,16 +230,20 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null); if (!FIXTURES && !status?.hasKey) { console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -139,15 +139,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -145,15 +145,19 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`); - process.exit(2); + process.exitCode = 2; + return; } const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (!status?.hasKey) { console.error('Server has no TomTom key — the A/B needs live flow.'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..424f3486e 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -113,17 +113,21 @@ async function main() { try { const res = await fetch(APP_URL); + // Only the status matters; an unread body can hold the socket open. + await res.body?.cancel(); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); - process.exit(2); + process.exitCode = 2; + return; } const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); - process.exit(2); + process.exitCode = 2; + return; } fs.mkdirSync(SHOTS_DIR, { recursive: true }); From 504d56ba2b3d73dcad45c14cd0715764c8cb2955 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 14 Oct 2026 17:58:53 -0400 Subject: [PATCH 52/78] test: fix flaky timing and timeout guards (#969) Clears the timeout guards once each race settles, measures the 15,000-vehicle transit ingestion with main-thread CPU time instead of wall time, and times the region-timeout case from the boundary lookup with a 2 s ceiling, below the 3 s default budget, so an ignored budget still fails. Co-authored-by: Sameh Khamis --- server/providers/transitHistory.test.mjs | 16 +++++++++------- src/annotations/regionRing.test.mjs | 7 +++++-- src/data/manager.test.mjs | 14 ++++++++++---- src/sdr/controller.test.mjs | 9 +++++---- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..cb06c182a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde const rows = Array.from({ length: 15000 }, (_, i) => row(now, `vehicle-${i}`), ); - const start = performance.now(); + // Main-thread CPU: excludes scheduler waits and parallel GC helper threads. + const start = process.threadCpuUsage(); h.ingest(feed, rows, now); - times.push(performance.now() - start); + const usage = process.threadCpuUsage(start); + times.push((usage.user + usage.system) / 1000); } assert.equal(h.diagnostics().keys, 15000); for (let i = 0; i < 15000; i++) { @@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde } assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024); t.diagnostic( - `15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`, + `15000-vehicle ingestion CPU ms: ${times.map((n) => n.toFixed(1)).join('/')}`, ); - // A ceiling that says "not quadratic", not a benchmark: 15,000 rows in - // well under two seconds on a loaded laptop or a slow CI runner. The - // per-run figure above is the diagnostic to read. - assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`); + // CPU time measures ingestion work without counting scheduler delays on + // loaded runners. Keep the two-second ceiling as a guard against quadratic + // work, not a benchmark; the per-poll figures above show any slowdown. + assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`); }); test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => { diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..cd8ad6447 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () => test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => { let adminSignal; let release; + let lookupStarted; const { resolveRegionRingForQuery } = createAnnotationResolver({ featureSource: featureSource({ getAdministrativeAreas(_point, { signal }) { + lookupStarted = Date.now(); adminSignal = signal; return new Promise((resolve) => { release = resolve; @@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th }, }), }); - const started = Date.now(); const region = await resolveRegionRingForQuery( 'Atlantis', undefined, @@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th ring: null, error: 'region-timeout', }); + // Timed from the boundary lookup, so the bundled pack scans before it do not + // count. Under the 3 s default budget, so an ignored budgetMs still fails. assert.ok( - Date.now() - started < 1000, + Date.now() - lookupStarted < 2_000, 'returns at the budget, not the lookup', ); // The lookup is left running so it can fill the boundary cache. diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index fce64ae8f..23641c962 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi removeGuard(); // The newest intent must be able to abort the hung compensation and run. const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' }); + let giveUpTimer; const finalOn = await Promise.race([ pendingFinalOn, - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation'); assert.equal(compensationAborted, true, 'the hung compensation was aborted'); assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled'); @@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp releaseFirstDisable(); const blockedOn = await pendingBlockedOn; + let giveUpTimer; const reentrant = await Promise.race([ (async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(), - new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)), - ]); + new Promise((resolve) => { + giveUpTimer = setTimeout(() => resolve('starved'), 2000); + }), + ]).finally(() => clearTimeout(giveUpTimer)); await pendingOff; await mgr.waitForLayerSettled('flights'); unsubscribe(); diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs index 95b1eb5f2..f8078dbe5 100644 --- a/src/sdr/controller.test.mjs +++ b/src/sdr/controller.test.mjs @@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn /** Resolve with the promise's value, or 'timed out' after `ms`. */ function within(promise, ms = 1_000) { - return Promise.race([ - promise, - new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)), - ]); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve('timed out'), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } /** From cbefd529f9ca08d55e874240142d495cdf1d342f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:05 -0700 Subject: [PATCH 53/78] fix(director): turn heading and roll the short way for any authored angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single % made the seek delta negative when the target angle was more than 540° below the start, so 350° to -350° turned 340° through 180°. Uses the same double modulo as playback, and adds tests for exact ±180° endpoints and a near-antipodal seek. --- docs/CURRENT-STATE.md | 4 ++- src/director/director-dateline-seek.test.mjs | 27 ++++++++++++++++++++ src/director/timeline.js | 4 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa48b7bfe..3a7f7b94f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,7 +399,9 @@ camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid reference. Legacy projects retain their ordinary flights, content and edits. -Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes. +Seeking between ordinary camera poses uses the shortest longitude arc across the +dateline while preserving both authored endpoint longitudes, and turns heading +and roll the short way for any authored angles. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs index f950814f3..b0dd2b24b 100644 --- a/src/director/director-dateline-seek.test.mjs +++ b/src/director/director-dateline-seek.test.mjs @@ -24,3 +24,30 @@ test('director seek follows the short dateline arc in both directions', () => { } assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22); }); + +test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => { + for (const [a, b] of [ + [180, -180], + [-180, 180], + ]) { + assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a); + assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b); + assert.equal( + Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon), + 180, + ); + } + // 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east. + const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon; + assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`); +}); + +test('director seek turns heading and roll the short way for any authored angles', () => { + const pose = (heading, roll) => ({ ...camera(0), heading, roll }); + const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5); + // 350° and -350° are 20° apart through 0°/360°, not 340° through 180°. + for (const value of [mid.heading, mid.roll]) { + const wrapped = ((value % 360) + 360) % 360; + assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`); + } +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index fbaca4301..f7cffcece 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -41,7 +41,9 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased; const lerpAngle = (from, to) => { const start = Number(from) || 0; - const delta = ((Number(to) - start + 540) % 360) - 180; + // Double modulo keeps the delta in [-180, 180) for any authored angles, + // as sampleCameraMove does; a single % goes negative below -540. + const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180; return start + delta * eased; }; return { From c12938cd05b843ca00e8bf0ac4be5d5c5770acaa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:40 -0700 Subject: [PATCH 54/78] perf(tools): stop counting string code points once the bounds are decided Strings without length bounds are not counted, and counting stops at the larger bound, so a huge tool argument costs no more than its limit. --- src/tools/schema.js | 20 ++++++++++++++------ src/tools/tool-schema-codepoints.test.mjs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/tools/schema.js b/src/tools/schema.js index b6e8e2fd0..f857825a1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,12 +106,20 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - let length = 0; - for (const character of value) length += 1; - if (schema.minLength != null && length < schema.minLength) - fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && length > schema.maxLength) - fail(`must be at most ${schema.maxLength} characters`); + if (schema.minLength != null || schema.maxLength != null) { + // Code points, as JSON Schema counts. Counting stops once both bounds + // are decided, so a huge argument costs no more than its limit. + const limit = Math.max( + schema.minLength ?? 0, + schema.maxLength != null ? schema.maxLength + 1 : 0, + ); + let length = 0; + for (const _ of value) if (++length >= limit) break; + if (schema.minLength != null && length < schema.minLength) + fail(`must be at least ${schema.minLength} characters`); + if (schema.maxLength != null && length > schema.maxLength) + fail(`must be at most ${schema.maxLength} characters`); + } if ( schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value) diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs index c379b77e9..27af78fa3 100644 --- a/src/tools/tool-schema-codepoints.test.mjs +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -18,3 +18,16 @@ test('tool string schemas count Unicode characters rather than UTF-16 units', () ); assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1); }); + +test('tool string bounds hold at their edges and on huge arguments', () => { + const string = (bounds) => ({ type: 'string', ...bounds }); + assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []); + assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1); + assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []); + assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []); + // Counting all of it takes roughly 0.7 s; stopping at the limit takes none. + const huge = 'x'.repeat(1e8); + const started = performance.now(); + assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1); + assert.ok(performance.now() - started < 200, 'stops counting past the limit'); +}); From 5be2185dc79353cf76969a13c4372b20b15428d2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:06:52 -0700 Subject: [PATCH 55/78] fix(weather): treat a missing code as unavailable in cloud effects too Number(null) is 0, so the cockpit cloud profile treated a missing weather code as clear but available. The briefing's three checks now share one helper, and the current-state note is its own bullet instead of folding into the route one. --- docs/CURRENT-STATE.md | 3 +-- src/data/regionalModel.js | 41 ++++++++++++--------------------- src/weatherEffectsMath.js | 8 ++++++- src/weatherEffectsMath.test.mjs | 9 ++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f62c77bab..0ec9548ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4632,8 +4632,7 @@ are omitted rather than framing the wrong part of the globe. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. -Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. - +- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. - **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 90eeac1fd..3a6d79f14 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -87,27 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { return articles; } +/** A reported number, or null when the observation is missing or not numeric. */ +function observedNumber(value) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; + return Number.isFinite(Number(value)) ? Number(value) : null; +} + /** Normalize Open-Meteo current conditions into a small source-stamped record. */ export function normalizeRegionalWeather(payload) { const current = payload?.current; - if ( - !current || - current.temperature_2m == null || - typeof current.temperature_2m === 'boolean' || - (typeof current.temperature_2m === 'string' && - !current.temperature_2m.trim()) || - !Number.isFinite(Number(current.temperature_2m)) - ) - return null; - const numberOrNull = (value) => { - if ( - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()) - ) - return null; - return Number.isFinite(Number(value)) ? Number(value) : null; - }; + if (!current || observedNumber(current.temperature_2m) == null) return null; + const numberOrNull = observedNumber; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. const observedRaw = @@ -132,14 +127,8 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { - if ( - code == null || - typeof code === 'boolean' || - (typeof code === 'string' && !code.trim()) - ) - return 'CONDITIONS UNKNOWN'; - const value = Number(code); - if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; + const value = observedNumber(code); + if (value == null) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; if (value === 3) return 'OVERCAST'; diff --git a/src/weatherEffectsMath.js b/src/weatherEffectsMath.js index 0c1276bab..ec06741aa 100644 --- a/src/weatherEffectsMath.js +++ b/src/weatherEffectsMath.js @@ -13,7 +13,13 @@ function range01(value, min, max) { * Missing weather fails clear instead of inventing conditions. */ export function deriveWeatherEffectProfile(weather) { - if (!weather || !Number.isFinite(Number(weather.weatherCode))) { + const reported = weather?.weatherCode; + // Number(null) and Number('') are 0 (clear), so a missing code is checked first. + if ( + reported == null || + reported === '' || + !Number.isFinite(Number(reported)) + ) { return { available: false, cloud: 0, diff --git a/src/weatherEffectsMath.test.mjs b/src/weatherEffectsMath.test.mjs index 6ca4387bd..bed3d1cd4 100644 --- a/src/weatherEffectsMath.test.mjs +++ b/src/weatherEffectsMath.test.mjs @@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => { assert.equal(profile.storm, 0); }); +test('a missing weather code is unavailable, not clear', () => { + for (const weatherCode of [null, undefined, '']) { + const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 }); + assert.equal(profile.available, false, `weatherCode ${weatherCode}`); + assert.equal(profile.cloud, 0); + } + assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true); +}); + test('clear weather follows observed cloud cover without precipitation', () => { const profile = deriveWeatherEffectProfile({ weatherCode: 1, From e50bc45674c4e7f940dce07f3c67b7c1ed83fb67 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:36:35 -0700 Subject: [PATCH 56/78] fix(qa): run the macOS-only gates on Windows and Linux (#1061) Seven QA scripts hard-coded macOS Google Chrome. They now share chromeExecutablePath(): PUPPETEER_EXECUTABLE_PATH when set, then macOS Chrome when installed, otherwise Puppeteer's managed browser. The cables screenshot path is built with fileURLToPath so it works on Windows. qa-overlay-baseline keeps its fixed Chrome because its baselines record the executable. Co-authored-by: Sameh Khamis --- scripts/format-scope.json | 2 ++ scripts/precompute-cctv-heights.mjs | 4 ++-- scripts/qa-cables-shot.mjs | 5 ++++- scripts/qa-chrome.mjs | 20 ++++++++++++++++++++ scripts/qa-directions.mjs | 4 ++-- scripts/qa-draw-tool.mjs | 4 ++-- scripts/qa-recent-imagery.mjs | 4 ++-- scripts/qa-transit-heading.mjs | 4 ++-- scripts/qa-transit-recovery.mjs | 4 ++-- scripts/qa-transit.mjs | 4 ++-- src/qaChrome.test.mjs | 27 +++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 scripts/qa-chrome.mjs create mode 100644 src/qaChrome.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a880b284..296880561 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -934,6 +934,8 @@ "scripts/qa-street-level.mjs", "src/tooling/streetLevelQa.test.mjs", "scripts/qa-browserEvidence.mjs", + "scripts/qa-chrome.mjs", + "src/qaChrome.test.mjs", "scripts/fixtures/street-level/mapillaryGraph.mjs", "scripts/fixtures/street-level/kartaView.mjs", "src/layers/streetLevel/filter.test.mjs", diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs index 24813b5e7..648768341 100644 --- a/scripts/precompute-cctv-heights.mjs +++ b/scripts/precompute-cctv-heights.mjs @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { planeSupportPoints, poseHash, @@ -389,8 +390,7 @@ async function main() { const { default: puppeteer } = await import('puppeteer'); browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), protocolTimeout: 600000, args: [ '--no-sandbox', diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 12af9beb8..3e3008e03 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -6,6 +6,7 @@ */ import puppeteer from 'puppeteer'; import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; const argv = process.argv; const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214'; @@ -65,7 +66,9 @@ try { requestAnimationFrame(tick); })); await new Promise((r) => setTimeout(r, 1_000)); - const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname; + const path = fileURLToPath( + new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url), + ); await page.screenshot({ path }); console.log(`saved ${path}`); } diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs new file mode 100644 index 000000000..05c4dbff0 --- /dev/null +++ b/scripts/qa-chrome.mjs @@ -0,0 +1,20 @@ +/** + * The browser for gates that drive an installed Chrome. These gates were + * written against macOS Google Chrome, which Windows and Linux do not have. + */ +import { existsSync } from 'node:fs'; + +export const MACOS_CHROME = + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +/** + * PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is + * installed. Otherwise undefined, so Puppeteer launches the browser it manages. + */ +export function chromeExecutablePath({ + env = process.env, + exists = existsSync, +} = {}) { + if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; + return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; +} diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 9501690f9..ca271026f 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -10,6 +10,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); @@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => { const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index a5a16af1d..adb29f3af 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw'; @@ -22,8 +23,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 5d42279dc..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -20,6 +20,7 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery'; @@ -54,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -1,11 +1,11 @@ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; /** Prove parked and moving headings in the real scene, without live feeds. */ export async function runTransitHeadingRegression(base, check) { const browser = await puppeteer.launch({ headless: true, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: ['--no-sandbox', '--disable-background-timer-throttling'], }); try { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..5f6404b41 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,5 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; await mkdir(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: false, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--disable-background-timer-throttling', '--disable-renderer-backgrounding', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..7d83d77a4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,6 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true }); const browser = await puppeteer.launch({ headless: false, protocolTimeout: 150000, - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), args: [ '--no-sandbox', '--disable-background-timer-throttling', diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs new file mode 100644 index 000000000..4414f3c93 --- /dev/null +++ b/src/qaChrome.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; + +test('gates keep macOS Chrome where it is installed', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }), + MACOS_CHROME, + ); +}); + +test('gates fall back to Puppeteer’s browser without macOS Chrome', () => { + assert.equal( + chromeExecutablePath({ env: {}, exists: () => false }), + undefined, + ); +}); + +test('an explicit executable path wins', () => { + assert.equal( + chromeExecutablePath({ + env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' }, + exists: () => true, + }), + 'C:\\Chrome\\chrome.exe', + ); +}); From 51d335b1db56a767ceab9aed3238889aaccebcdd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:10 -0700 Subject: [PATCH 57/78] test: check the route cap at exactly 200 turns The existing "route that fits" case used 199 steps, which is how the off-by-one went unnoticed. Folds the docs note into the existing cap sentence and makes the changelog entry specific. --- CHANGELOG.md | 3 ++- docs/CURRENT-STATE.md | 11 ++++------- src/data/routeSteps.test.mjs | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31fa3b502..a770fbd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. +- Directions no longer shows a truncation notice for a route with exactly 200 + turns, and a roundabout at the 200th turn keeps its exit distance. - Weather records keep missing conditions unknown and refuse summaries without a measured temperature. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c98b2f667..1f794a5b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3327,10 +3327,6 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. -Directions retains up to 200 turn instructions. Its truncation notice appears -only when another independent instruction is omitted; a complete route with -exactly 200 turns does not show the notice. - 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 profile; SET A and SET B arm the next globe click (Escape or a second press @@ -3403,9 +3399,10 @@ rate limit from the routing service is reported as one instead of as "no route found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at -200 maneuvers — a route past that cap is reported as cut off, in the row -summary and as the last line of the list, because the arrival step is among -the ones dropped. +200 maneuvers — a route with more is reported as cut off, in the row summary +and as the last line of the list, because the arrival step is among the ones +dropped. A route of exactly 200 is complete, and a roundabout exit after the +200th keeps folding into it. A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index a3c8cf227..6bf74dd08 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () = // would present a route that simply stops in the middle of a road. assert.equal(cut.truncated, true); const exact = normalizeOsrmSteps({ - legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }], + legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }], }); - assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1); + assert.equal(exact.steps.length, ROUTE_STEPS_MAX); assert.equal(exact.truncated, false, 'a route that fits is not cut'); }); From 84714030c86ca42a770aa04e19ae52fabf457dc8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:47 -0700 Subject: [PATCH 58/78] test: show a capped overview no longer holds repeat tiles Adds a case that fails before de-duplication (a 24-slot overview held repeats and reported itself truncated) and says why the date-line halves overlap. --- .../streetLevel/street-level-unique-tiles.test.mjs | 9 +++++++++ src/layers/streetLevel/tileMath.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index 224de523b..52e7b6aea 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -20,3 +20,12 @@ test('reachable polar dateline coverage ranks 24 distinct tiles before overview ); assert.equal(overview.truncated, true); }); + +test('a limit that fits every distinct tile is not spent on repeats', () => { + // Before de-duplication this box yielded 27 entries for 24 tiles, so a + // 24-slot overview held repeats and still reported itself truncated. + const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 }); + const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`); + assert.equal(new Set(keys).size, 24); + assert.equal(result.truncated, false); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index ca7b09cd5..350188200 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -146,7 +146,10 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { const dx = Math.abs(tile.x - centre.x); return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2; }; - // Each tile's distance is computed once; ties keep their row order. + // The date-line halves share a column when the gap between east and west is + // narrower than a tile; count that column once so a capped overview does not + // spend its slots on repeats. Each tile's distance is computed once; ties keep + // their row order. const unique = [ ...new Map( tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]), From c5ac24b6005d4c070378a99544fd78d6bc21ef09 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:12 -0400 Subject: [PATCH 59/78] fix(recent-imagery): reject absent box edges instead of inventing zero degrees --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++++ .../imagery-box-missing-edge.test.mjs | 16 ++++++++++++++++ src/layers/recentImagery/model.js | 17 +++++++++++------ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/imagery-box-missing-edge.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..95a92c46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..1ad836aba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(recent-imagery): reject absent box edges instead of inventing zero degrees + +Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs new file mode 100644 index 000000000..3e5a8e8a5 --- /dev/null +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateBox, boxCentre } from './model.js'; +test('missing imagery edges do not turn into invented zero coordinates', () => { + for (const value of [null, undefined, '', ' ', false, true]) { + const box = { west: value, south: 0, east: 1, north: 1 }; + assert.equal(validateBox(box).ok, false); + assert.equal(validateBox(box).reason, 'invalid'); + assert.equal(boxCentre(box), null); + } + assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true); + assert.equal( + validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok, + true, + ); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 23f503e9e..63c5ec16c 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -77,12 +77,17 @@ const pad2 = (value) => String(value).padStart(2, '0'); */ function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const [west, south, east, north] = [ - box.west, - box.south, - box.east, - box.north, - ].map(Number); + const edges = [box.west, box.south, box.east, box.north]; + if ( + edges.some( + (value) => + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()), + ) + ) + return null; + const [west, south, east, north] = edges.map(Number); if (![west, south, east, north].every(Number.isFinite)) return null; return { west, From 9c9c80a7ed489c5ba1bb4a7159e87103e7ae25d2 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:03 -0400 Subject: [PATCH 60/78] docs: address review scope and feature placement for #1036 --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a92c46f..dddcdeef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Director timeline seeks cross the dateline along the short longitude arc. - Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. +- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or + boolean programmatic box edges instead of interpreting them as zero coordinates. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1ad836aba..054aff963 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,11 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +Programmatic boxes passed to the exported `setBox` and `searchHls` operations +reject missing, blank or boolean edges instead of interpreting them as zero +coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, +pins and share links already supply complete boxes. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. @@ -5326,8 +5331,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(recent-imagery): reject absent box edges instead of inventing zero degrees - -Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. From 6ffbf7f5244ccaaf8959118ad857efe8761e58a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:36:29 -0700 Subject: [PATCH 61/78] fix(imagery): refuse blank box edges at every conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share-link integers and Cesium radians were converted before normalizeBox checked them, so a blank edge still became 0°. Edges are now accepted only as numbers or non-blank numeric strings, which also refuses [] and {}. Tests cover all four edges through validateBox, dequantizeBox, boxFromRectangle and searchHls. --- CHANGELOG.md | 1 - .../imagery-box-missing-edge.test.mjs | 34 +++++++++++++++- src/layers/recentImagery/model.js | 40 +++++++++++-------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddcdeef8..9ab3b03cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. -- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or boolean programmatic box edges instead of interpreting them as zero coordinates. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs index 3e5a8e8a5..698bcaee5 100644 --- a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { validateBox, boxCentre } from './model.js'; +import { + boxCentre, + boxFromRectangle, + dequantizeBox, + validateBox, +} from './model.js'; +import { searchHls } from './catalog.js'; + test('missing imagery edges do not turn into invented zero coordinates', () => { for (const value of [null, undefined, '', ' ', false, true]) { const box = { west: value, south: 0, east: 1, north: 1 }; @@ -14,3 +21,28 @@ test('missing imagery edges do not turn into invented zero coordinates', () => { true, ); }); + +test('every edge and every box entry point refuses a missing edge', async () => { + const blanks = [null, undefined, '', ' ', false, [], {}]; + for (const edge of ['west', 'south', 'east', 'north']) { + for (const value of blanks) { + const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value }; + assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`); + // Share-link integers and Cesium radians are converted per edge. + assert.equal(dequantizeBox(box), null, `share ${edge}`); + assert.equal(boxFromRectangle(box), null, `radians ${edge}`); + } + } + let fetched = false; + await assert.rejects( + searchHls({ + box: { west: ' ', south: 1, east: 2, north: 2 }, + fetchImpl: async () => { + fetched = true; + }, + }), + TypeError, + ); + assert.equal(fetched, false); + assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 })); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 63c5ec16c..24ef54fc6 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -75,20 +75,23 @@ const pad2 = (value) => String(value).padStart(2, '0'); * east are not swapped: west > east crosses the dateline and `validateBox` * rejects it. Null when any edge is nonfinite. */ +/** A box edge as a number, or null when it is missing, blank or not numeric. */ +function edgeNumber(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string' || !value.trim()) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const edges = [box.west, box.south, box.east, box.north]; - if ( - edges.some( - (value) => - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()), - ) - ) - return null; - const [west, south, east, north] = edges.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return null; + const [west, south, east, north] = [ + box.west, + box.south, + box.east, + box.north, + ].map(edgeNumber); + if ([west, south, east, north].some((edge) => edge == null)) return null; return { west, south: Math.min(south, north), @@ -216,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) { function mapBox(box, fn) { if (!box || typeof box !== 'object') return null; + // Converting first would turn a blank edge into 0 before normalizeBox sees it. + const edge = (value) => { + const number = edgeNumber(value); + return number == null ? null : fn(number); + }; return normalizeBox({ - west: fn(box.west), - south: fn(box.south), - east: fn(box.east), - north: fn(box.north), + west: edge(box.west), + south: edge(box.south), + east: edge(box.east), + north: edge(box.north), }); } From 9f56e9df981eeb6f090c757d36bd081415edb34d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:47:56 -0400 Subject: [PATCH 62/78] fix(terrain): refresh cache recency when resolving warm batches --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/services/terrain-warm-batch-lru.test.mjs | 24 ++++++++++++++++++++ src/services/terrainHeights.js | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..e3075234c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..246f3067c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(terrain): refresh cache recency when resolving warm batches + +Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs new file mode 100644 index 000000000..ac718b998 --- /dev/null +++ b/src/services/terrain-warm-batch-lru.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTerrainHeights } from './terrainHeights.js'; +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index a6502aeb5..124777150 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -273,7 +273,7 @@ export function createTerrainHeights({ const seenKeys = new Set(); const now = Date.now(); for (const item of work) { - const entry = cache.get(item.key); + const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && From 07757a3cae25956f362525432bf2b45bb1908bd3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:26 -0400 Subject: [PATCH 63/78] docs: address maintainer review for PR #1040 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 246f3067c..fcb2f3b3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,8 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +Warm batch lookups also promote their cached entries in the least-recently-used eviction order. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(terrain): refresh cache recency when resolving warm batches - -Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. From b4aa61d6ef53982ed2cf34f0eea65cabf181c86d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:37:30 -0700 Subject: [PATCH 64/78] fix(terrain): keep a batch's warm points when its own fetches evict them A warm entry could be evicted by the same call's later fetches and then reported unresolved. The call now remembers warm hits as it does fetched ones. Moves the batch LRU test beside the existing cache tests and folds the docs note into the section's eviction paragraph. --- docs/CURRENT-STATE.md | 10 ++--- src/services/terrain-warm-batch-lru.test.mjs | 24 ------------ src/services/terrainHeights.js | 20 +++++----- src/services/terrainHeights.test.mjs | 41 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 41 deletions(-) delete mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcb2f3b3f..d28fee7e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,19 +1192,17 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound -Warm batch lookups also promote their cached entries in the least-recently-used eviction order. - The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated visits to one place share a key but does not bound how many distinct places a session visits, and the cache previously had no eviction, so a long session retained every coordinate it ever resolved. Eviction is least-recently-used: -a consumer read promotes its entry, so the cell being watched is not dropped for -having been resolved early. The bound sits far above a city-scale session, so +a consumer read, including a warm hit in a batch lookup, promotes its entry, so +the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so normal use never evicts and issues no additional proxy requests. A batch's -results are assembled from what that call resolved, so eviction during a large -batch cannot report a just-resolved point as unresolved. Re:Earth and +results are assembled from what that call resolved or found warm, so eviction +during a large batch cannot report one of its points as unresolved. Re:Earth and geoid-fallback entry semantics, the fallback cooldown and the abort-time flush are unchanged. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs deleted file mode 100644 index ac718b998..000000000 --- a/src/services/terrain-warm-batch-lru.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTerrainHeights } from './terrainHeights.js'; -test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { - let calls = 0; - const terrain = createTerrainHeights({ - maxCacheEntries: 2, - source: { - async getHeights(points) { - calls++; - return points.map((p) => ({ ellipsoid: 100 + p.lon })); - }, - }, - }); - const p = (lon) => ({ lat: 0, lon }); - await terrain.resolveEllipsoidalGround([p(1)]); - await terrain.resolveEllipsoidalGround([p(2)]); - await terrain.resolveEllipsoidalGround([p(1)]); - assert.equal(calls, 2); - await terrain.resolveEllipsoidalGround([p(3)]); - assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); - assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); - assert.equal(calls, 3); -}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index 124777150..f84a4413f 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -272,28 +272,26 @@ export function createTerrainHeights({ const uncached = []; const seenKeys = new Set(); const now = Date.now(); + // What this call resolved or found warm, so the assembly below can report a + // point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. + // Without it a batch big enough to evict its own entries would report + // points as unresolved that it had in fact resolved. + const resolvedThisCall = new Map(); for (const item of work) { const entry = cacheTouch(item.key); const fallbackCooling = entry?.source === 'geoid-fallback' && Number.isFinite(entry.retryAt) && now < entry.retryAt; - if ( - (entry && entry.source === 'reearth') || - fallbackCooling || - seenKeys.has(item.key) - ) + if ((entry && entry.source === 'reearth') || fallbackCooling) { + resolvedThisCall.set(item.key, entry); continue; + } + if (seenKeys.has(item.key)) continue; seenKeys.add(item.key); uncached.push(item); } - // What this call resolved, so the assembly below can report a point even - // if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a - // batch big enough to evict its own earlier chunks would report points as - // unresolved that it had in fact just resolved. - const resolvedThisCall = new Map(); - // Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's // failure is isolated to that chunk's points (geoid fallback), so a single // bad chunk doesn't lose results for the rest of a large batch. diff --git a/src/services/terrainHeights.test.mjs b/src/services/terrainHeights.test.mjs index c9884e89c..5e80b9be2 100644 --- a/src/services/terrainHeights.test.mjs +++ b/src/services/terrainHeights.test.mjs @@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved' assert.equal(warm.length, 4); }); +test('warm batch consumers retain their terrain cells ahead of unused cells', async () => { + let calls = 0; + const terrain = createTerrainHeights({ + maxCacheEntries: 2, + source: { + async getHeights(points) { + calls++; + return points.map((p) => ({ ellipsoid: 100 + p.lon })); + }, + }, + }); + const p = (lon) => ({ lat: 0, lon }); + await terrain.resolveEllipsoidalGround([p(1)]); + await terrain.resolveEllipsoidalGround([p(2)]); + await terrain.resolveEllipsoidalGround([p(1)]); + assert.equal(calls, 2); + await terrain.resolveEllipsoidalGround([p(3)]); + assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101); + assert.equal(terrain.cachedEllipsoidalGround(0, 2), null); + assert.equal(calls, 3); +}); + +test('a batch still reports its warm points after its own fetches evict them', async () => { + const terrain = createTerrainHeights({ + source: stubSource(), + maxCacheEntries: 2, + }); + await terrain.resolveEllipsoidalGround([pointAt(0)]); + + const results = await terrain.resolveEllipsoidalGround([ + pointAt(0), + pointAt(1), + pointAt(2), + ]); + + results.forEach((result, i) => { + assert.equal(result.source, 'reearth', `point ${i} reported resolved`); + assert.equal(result.ellipsoid, heightAt(lonAt(i))); + }); +}); + test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => { for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) { const terrain = createTerrainHeights({ From d861f24d3e4456de567590ba253cd6a5b84a68f3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:11:57 -0400 Subject: [PATCH 65/78] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3075234c..80d2a4544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, From 8b68ff8412747986895811912ea4d5e26c4c0a9c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:44 -0400 Subject: [PATCH 66/78] fix(firms): reject missing and out-of-range detection coordinates --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/data/firms-coordinate-evidence.test.mjs | 31 +++++++++++++++++++++ src/data/firmsAdapt.js | 13 ++++++++- src/data/firmsCsv.js | 10 ++++++- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/data/firms-coordinate-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..446192c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..967fbda64 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(firms): reject missing and out-of-range detection coordinates + +Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs new file mode 100644 index 000000000..494b38c18 --- /dev/null +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseFirmsCsv } from './firmsCsv.js'; +import { adaptFirmsRecords } from './firmsAdapt.js'; +test('missing positions do not become fire detections at zero coordinates', () => { + const csv = + 'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1'; + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [0, 0], + [-90, -180], + ], + ); + const records = [ + { lat: null, lon: 1 }, + { lat: '', lon: 1 }, + { lat: false, lon: 1 }, + { lat: 95, lon: 1 }, + { lat: 1, lon: 181 }, + { lat: '0', lon: 0 }, + { lat: -90, lon: -180 }, + ]; + assert.deepEqual( + adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]), + [ + [0, 0, 0], + [1, -90, -180], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 2fc909bce..b8f07ec2d 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -21,7 +21,18 @@ export function adaptFirmsRecords(records) { for (const record of records) { const lat = Number(record?.lat); const lon = Number(record?.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if ( + ![record?.lat, record?.lon].every( + (value) => + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== ''), + ) || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; fires.push({ index: fires.length, lat, diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..c89d10a0a 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,7 +105,15 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; + if ( + !parts[iLat].trim() || + !parts[iLon].trim() || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; records.push({ lat, From 5a14a06a52ca70517b1a6199177d1b4ea2a47f0c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:28 -0400 Subject: [PATCH 67/78] docs: address maintainer review for PR #1044 --- docs/CURRENT-STATE.md | 7 ++----- src/data/firmsAdapt.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 967fbda64..4c33b1744 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential. + The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(firms): reject missing and out-of-range detection coordinates - -Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index b8f07ec2d..3e3f27631 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -22,11 +22,8 @@ export function adaptFirmsRecords(records) { const lat = Number(record?.lat); const lon = Number(record?.lon); if ( - ![record?.lat, record?.lon].every( - (value) => - typeof value === 'number' || - (typeof value === 'string' && value.trim() !== ''), - ) || + !hasCoordinateValue(record?.lat) || + !hasCoordinateValue(record?.lon) || !Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || @@ -121,3 +118,10 @@ function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } + +function hasCoordinateValue(value) { + return ( + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== '') + ); +} From e26a715b04968cac14646735232ce75b32716793 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:33 -0400 Subject: [PATCH 68/78] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446192c88..077d7c5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, From cd7c23b36fcee32f646723456bd1a7d163b86d9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:47 -0400 Subject: [PATCH 69/78] fix(director): reject cancellation during the final asset stream read --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++ .../packs/asset-stream-cancellation.test.mjs | 42 +++++++++++++++++++ src/director/packs/source.js | 1 + 4 files changed, 49 insertions(+) create mode 100644 src/director/packs/asset-stream-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..a7f8ca53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..20e346aad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): reject cancellation during the final asset stream read + +Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs new file mode 100644 index 000000000..cd4f2490d --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAssetDirectorySource } from './source.js'; +test('cancellation during the terminating read cannot return a scene asset', async () => { + for (const cancel of [true, false]) { + const controller = new AbortController(); + let reads = 0, + cancelled = 0, + released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + body: { + getReader() { + return { + async read() { + reads++; + if (reads === 1) + return { done: false, value: new Uint8Array([1, 2]) }; + if (cancel) controller.abort(); + return { done: true }; + }, + async cancel() { + cancelled++; + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + const pending = source({ path: 'scene.bin', signal: controller.signal }); + if (cancel) await assert.rejects(pending, { name: 'AbortError' }); + else assert.deepEqual([...(await pending).bytes], [1, 2]); + assert.equal(cancelled, 1); + assert.equal(released, 1); + } +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 4828827af..052581a70 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -41,6 +41,7 @@ export function createAssetDirectorySource({ for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); + signal?.throwIfAborted(); if (done) break; length += value.byteLength; if (length > maxBytes) throw new Error('Asset exceeds byte limit'); From 04b712910d3e4793180f4bd66b1f198ebe704419 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:30 -0400 Subject: [PATCH 70/78] docs: address maintainer review for PR #1046 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 20e346aad..fae0cc8bf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,6 +396,8 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. +The asset directory source rejects with the caller's abort reason if cancellation arrives during any read, including the final one. + Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one coordinate sampler; navigation/manual input revokes authored motion and pending @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): reject cancellation during the final asset stream read - -Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. From 5889d353272723a75e8b0bfaacbc2085873e15f7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:51:10 -0400 Subject: [PATCH 71/78] fix(maps): stop delivering settled state to detached subscribers --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++ src/maps/controller.js | 1 + src/maps/map-subscription-ownership.test.mjs | 55 ++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/maps/map-subscription-ownership.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..362eb93e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Map state updates stop delivering to subscribers removed during the same notification. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..558963496 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(maps): stop delivering settled state to detached subscribers + +Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..d51b67082 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,6 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed || !this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs new file mode 100644 index 000000000..638de65a2 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapSourceController } from './controller.js'; +function fixture() { + return new MapSourceController( + { + scene: { + globe: { show: true }, + requestRender() {}, + frameState: { + creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, + }, + primitives: { add() {}, remove() {} }, + }, + imageryLayers: { add() {}, remove() {} }, + }, + { + registry: { + defaultId: 'fixture', + sources: [ + { + descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' }, + imagery: async () => ({}), + }, + ], + }, + createImageryLayer: (provider) => ({ provider }), + }, + ); +} +test('an unsubscribed owner is skipped in the same map settlement delivery', async () => { + const controller = fixture(); + const calls = []; + let detach; + controller.subscribe(() => { + calls.push('first'); + detach(); + }); + detach = controller.subscribe(() => calls.push('detached')); + controller.subscribe(() => calls.push('last')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'last']); + controller.destroy(); +}); +test('destroying the controller during delivery stops remaining listeners', async () => { + const controller = fixture(); + const calls = []; + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + }); + controller.subscribe(() => calls.push('detached')); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); From bbbdd0aeba19f9f73a0ba43db4a0a2d7c84b5b35 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:31 -0400 Subject: [PATCH 72/78] docs: address maintainer review for PR #1047 --- docs/CURRENT-STATE.md | 7 ++----- src/maps/controller.js | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 558963496..ef3720370 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5200,6 +5200,8 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination +A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. + Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(maps): stop delivering settled state to detached subscribers - -Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index d51b67082..6dd2720f7 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,7 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { - if (this._destroyed || !this._subscribers.has(listener)) continue; + if (!this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { From e225f0851b63f014f40233f9e611c79af215e394 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:15 -0400 Subject: [PATCH 73/78] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362eb93e7..168d5b9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Map state updates stop delivering to subscribers removed during the same notification. - Treat explicitly current, complete empty vessel coverage as a successful update, From 1f83897d7719977a5405045e9761e8554ff84315 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:26 -0400 Subject: [PATCH 74/78] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f8ca53d..44a6e5908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, From 6a7b89e436f282356375f6dff667ec8ffb0c1a30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:21:57 -0700 Subject: [PATCH 75/78] test(firms): cover both coordinate bounds and blank values on each axis Documents the adapter's skip rule and cross-references the two checks, which sit in separate packages (provider parser and fire layer). --- src/data/firms-coordinate-evidence.test.mjs | 38 +++++++++++++++++++++ src/data/firmsAdapt.js | 5 +-- src/data/firmsCsv.js | 2 ++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs index 494b38c18..d60a7ee37 100644 --- a/src/data/firms-coordinate-evidence.test.mjs +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -29,3 +29,41 @@ test('missing positions do not become fire detections at zero coordinates', () = ], ); }); + +test('both axes keep their exact bounds and drop values just past them', () => { + const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`; + const csv = [ + 'latitude,longitude,acq_date,acq_time,confidence,frp', + row(90, 180), + row(-90, -180), + row(90.000001, 0), + row(-90.000001, 0), + row(0, 180.000001), + row(0, -180.000001), + row(' ', 5), + row(5, '\t'), + ].join('\n'); + assert.deepEqual( + parseFirmsCsv(csv).map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); + const kept = adaptFirmsRecords([ + { lat: 90, lon: 180 }, + { lat: '-90', lon: '-180' }, + { lat: -90.000001, lon: 0 }, + { lat: 0, lon: -180.000001 }, + { lat: 5, lon: ' ' }, + { lat: 5, lon: undefined }, + { lat: [5], lon: 5 }, + ]); + assert.deepEqual( + kept.map((r) => [r.lat, r.lon]), + [ + [90, 180], + [-90, -180], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 3e3f27631..312838e80 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -7,8 +7,8 @@ */ /** - * Map proxy fire records into internal fire records. Records with - * non-finite coordinates are skipped; `index` is the post-skip position + * Map proxy fire records into internal fire records. Records with missing, + * non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position * (it keys pick ids and context-store ids, so it must stay sequential). * `contextEntity`/`position` start null and are lazily filled by the layer. * @param {?Array} records - /api/firms `fires` array. @@ -119,6 +119,7 @@ function finiteNumber(value) { return Number.isFinite(number) ? number : 0; } +// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero. function hasCoordinateValue(value) { return ( typeof value === 'number' || diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index c89d10a0a..aafeac50b 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,6 +105,8 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); + // Blank or out-of-range positions are dropped, never read as 0 (see + // hasCoordinateValue in firmsAdapt.js for the proxy-record side). if ( !parts[iLat].trim() || !parts[iLon].trim() || From bf4edb5bdb7697372109cf262c377c6b95c98512 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:12 -0700 Subject: [PATCH 76/78] fix(director): discard an asset cancelled during reader cleanup The finally block awaits reader.cancel(), so a cancel arriving there still let the bytes return. Rechecks the signal after cleanup, with a test that keeps the caller's abort reason, and wraps the current-state note. --- docs/CURRENT-STATE.md | 4 +- .../packs/asset-stream-cancellation.test.mjs | 37 +++++++++++++++++++ src/director/packs/source.js | 2 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a70f30e9..de4ca26ca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,7 +396,9 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. -The asset directory source rejects with the caller's abort reason if cancellation arrives during any read, including the final one. +The asset directory source rejects with the caller's abort reason if +cancellation arrives during any read, including the final one, or while the +reader is being released. Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs index cd4f2490d..eb67244ff 100644 --- a/src/director/packs/asset-stream-cancellation.test.mjs +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -40,3 +40,40 @@ test('cancellation during the terminating read cannot return a scene asset', asy assert.equal(released, 1); } }); + +test('cancellation during reader cleanup cannot return a scene asset', async () => { + const controller = new AbortController(); + const reason = new DOMException('scene changed', 'AbortError'); + let released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + body: { + getReader() { + let reads = 0; + return { + async read() { + reads++; + return reads === 1 + ? { done: false, value: new Uint8Array([1]) } + : { done: true }; + }, + async cancel() { + controller.abort(reason); + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + await assert.rejects( + source({ path: 'scene.bin', signal: controller.signal }), + (error) => error === reason, + ); + assert.equal(released, 1); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 052581a70..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -51,6 +51,8 @@ export function createAssetDirectorySource({ await reader.cancel().catch(() => {}); reader.releaseLock(); } + // Cleanup awaits too; a cancel during it must still discard the asset. + signal?.throwIfAborted(); const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { From b9fa0eedcc2367a018f501688a9bd6d4e86bb956 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:46 -0700 Subject: [PATCH 77/78] fix(maps): stop all delivery once the controller is destroyed A listener could destroy the controller and re-subscribe one still pending in the snapshot, which then ran. Delivery now stops at destruction and a destroyed controller takes no listeners. Adds ordering tests for adds, duplicates and re-adds during delivery, and moves the current-state note after the section intro. --- docs/CURRENT-STATE.md | 6 ++-- src/maps/controller.js | 4 ++- src/maps/map-subscription-ownership.test.mjs | 34 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8ecd0eae8..8474257b6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5209,8 +5209,6 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination -A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. - Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5218,6 +5216,10 @@ falls back to OSM on construction failure or two active tile failures, and credits follow the source actually displayed. Terrain remains lazy while the photoreal globe is hidden. +Map-stack subscribers hear each settled activation once. A subscriber removed +during a delivery, or every subscriber once the controller is destroyed, gets +no further callback from it, and a destroyed controller accepts no new ones. + Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied diff --git a/src/maps/controller.js b/src/maps/controller.js index 6dd2720f7..2b2ea462f 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -131,7 +131,8 @@ export class MapSourceController { * @returns {() => void} Unsubscribe. */ subscribe(listener) { - if (typeof listener !== 'function') return () => {}; + // A destroyed controller never settles again, so it takes no listeners. + if (typeof listener !== 'function' || this._destroyed) return () => {}; this._subscribers.add(listener); return () => { this._subscribers.delete(listener); @@ -142,6 +143,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed) return; if (!this._subscribers.has(listener)) continue; try { listener(state); diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs index 638de65a2..cf0708c09 100644 --- a/src/maps/map-subscription-ownership.test.mjs +++ b/src/maps/map-subscription-ownership.test.mjs @@ -53,3 +53,37 @@ test('destroying the controller during delivery stops remaining listeners', asyn await controller.setStack('fixture'); assert.deepEqual(calls, ['destroy']); }); + +test('a listener re-added after destroy during delivery is not called', async () => { + const controller = fixture(); + const calls = []; + const later = () => calls.push('later'); + controller.subscribe(() => { + calls.push('destroy'); + controller.destroy(); + controller.subscribe(later); + }); + controller.subscribe(later); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['destroy']); +}); + +test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => { + const controller = fixture(); + const calls = []; + const twice = () => calls.push('twice'); + const readded = () => calls.push('readded'); + let offReadded; + controller.subscribe(() => { + calls.push('first'); + controller.subscribe(() => calls.push('added-late')); + offReadded(); + controller.subscribe(readded); + }); + controller.subscribe(twice); + controller.subscribe(twice); + offReadded = controller.subscribe(readded); + await controller.setStack('fixture'); + assert.deepEqual(calls, ['first', 'twice', 'readded']); + controller.destroy(); +}); From fd5a1ef8569a313b249af271fe3d2e6f18817cfa Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:54:17 -0400 Subject: [PATCH 78/78] fix(tools): keep results when a timestamp is outside Date's range (#1045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isoTime returned toISOString() for any finite number, which throws past ±8.64e15 ms, so one bad record (e.g. a bike-share last_reported) failed the whole tool answer. Such timestamps are now null. The voice analyst's result rows had the same conversion and get the same guard. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/tools/results.js | 4 +- src/tools/tool-timestamp-range.test.mjs | 59 +++++++++++++++++++++++++ src/voice/gevActions.js | 5 ++- src/voice/gevActions.test.mjs | 20 +++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/tools/tool-timestamp-range.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6051fb3aa..b72aeefd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Frequently reused terrain batches retain their cached heights ahead of unused cells. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6ac31dd73..251b71a7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units. +Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row. + `gods-eye-view/tools` defines queries that answer questions from the app's data, and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client, diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..f9af7af3d 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) { /** Format an epoch millisecond time as ISO 8601, or null. */ export function isoTime(ms) { - return Number.isFinite(ms) ? new Date(ms).toISOString() : null; + if (!Number.isFinite(ms)) return null; + const date = new Date(ms); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; } /** "1 earthquake", "3 earthquakes". */ diff --git a/src/tools/tool-timestamp-range.test.mjs b/src/tools/tool-timestamp-range.test.mjs new file mode 100644 index 000000000..7aeee737b --- /dev/null +++ b/src/tools/tool-timestamp-range.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isoTime } from './results.js'; +import { getBikeShare } from './queries/mobility.js'; +test('out-of-range finite timestamps are unknown rather than throwing', async () => { + for (const value of [1e30, -1e30, NaN, Infinity, null]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z'); + assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z'); + assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z'); + for (const value of [8640000000000001, -8640000000000001, -Infinity]) + assert.equal(isoTime(value), null); + assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z'); + const station = { + station_id: 's1', + name: 'Fixture', + lat: 0, + lon: 0, + capacity: 4, + }; + const result = await getBikeShare.run( + { area: { bbox: [-1, -1, 1, 1] } }, + { + services: { + bikeshare: { + systems: [ + { + id: 'fixture', + city: 'Fixture', + centerLat: 0, + centerLon: 0, + loadRadiusKm: 30, + stationInformationUrl: 'information', + stationStatusUrl: 'status', + }, + ], + async getStations(url) { + return { + data: { + stations: + url === 'information' + ? [station] + : [ + { + station_id: 's1', + last_reported: 1e30, + num_bikes_available: 2, + }, + ], + }, + }; + }, + }, + }, + }, + ); + assert.equal(result.data.returned, 1); + assert.equal(result.data.rows[0].last_reported, null); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..267f633ff 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4808,8 +4808,9 @@ function compactAnalystItem(r) { for (const [key, spec] of Object.entries(fields)) { if (!present(r[key]) || key in compact) continue; if (spec.type === 'time') { - const ms = Number(r[key]); - if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString(); + // A finite value can still be past Date's range, where toISOString throws. + const time = new Date(Number(r[key])); + if (Number.isFinite(time.getTime())) compact.time = time.toISOString(); } else compact[key] = r[key]; } if (present(r.ageHours)) compact.ageHours = r.ageHours; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..2a80b829c 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3459,6 +3459,26 @@ test('analyst_query refusals carry their code and the allowed values', async () assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/); }); +test('analyst_query keeps its answer when one record has an out-of-range time', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const now = Date.now(); + const earthquakes = { + getStats: () => ({ count: 2, lastUpdate: now }), + getAnalystRecords: () => [ + { id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now }, + { id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 }, + ], + }; + const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] }); + const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } }); + assert.equal(result.ok, true); + const q = result.items.find((item) => item.id === 'q'); + assert.match(q.time, /^\d{4}-\d\d-\d\dT/); + const r = result.items.find((item) => item.id === 'r'); + assert.ok(r, 'the record is still listed'); + assert.equal(r.time, undefined, 'an unrepresentable time is omitted'); +}); + test('spoken layer names from the manifest reach the newer layers', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const stub = () => ({ getStats: () => ({ count: 0 }) });