From c99eb931740e2132ed929670c8afd89bdc128461 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 00:36:58 +0100 Subject: [PATCH] =?UTF-8?q?fix(street-level):=20KartaView=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20context=20loss=20mid-load,=20chunked=20reads,=20sou?= =?UTF-8?q?rce=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; }