fix(street-level): KartaView PR review — context loss mid-load, chunked reads, source switches, a11y

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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 00:36:58 +01:00
1 parent 97a1a667de
commit c99eb93174
12 files changed
+512 -26

No files matched your search

+11 -6
View File
@@ -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/<base64url>`), then 2,560 px, from the image proxy (`pr:sharp/rs:fit:W:H/<base64url>`),
with KartaView's own 1,280 px file as the fallback, and a 360° photo's 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 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 1,024 px, then 2,048 (docked) or 4,096 px (expanded, or once the viewer
centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the grows), decoded off the main thread into an ImageBitmap and uploaded as RGBA
canvas re-measures every frame). Without WebGL, or if the proxy fails within (about 90 ms → 15 ms for 4,096 px on a phone-class CPU), on `panorama.js`, a
15 s, a 360° photo shows its front view; a lost context draws the photo again single-triangle WebGL renderer (image centre = the camera heading; drag,
on a new canvas (a lost canvas only gives back its lost context), through the pinch, wheel, Shift+← →, ↑ ↓, + −; the canvas re-measures every frame; a
core and only if no newer photo is opening. FIT / FILL hide for panoramas (`supportsRenderModes`). The 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 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 the full-size photo through the image proxy, since kartaview.org's photo
pages fail for signed-out visitors. pages fail for signed-out visitors.
@@ -33,6 +33,11 @@ export function resetKartaViewFixture() {
nearestLookups = 0; 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. */ /** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() { export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT; const step = KV_SPACING_M / METRES_PER_DEG_LAT;
+55 -2
View File
@@ -42,7 +42,9 @@ import {
describeKartaViewCall, describeKartaViewCall,
KV_LINE, KV_LINE,
KV_SEQUENCE_ID, KV_SEQUENCE_ID,
kartaViewLookups,
kartaViewPhotos, kartaViewPhotos,
resetKartaViewFixture,
} from './fixtures/street-level/kartaView.mjs'; } from './fixtures/street-level/kartaView.mjs';
const PANEL_ID = 'street-level-panel'; 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. * draped map so a line is where it is drawn; leaves Mapillary lit.
*/ */
async function kartaViewSteps() { async function kartaViewSteps() {
// The first lookup from here on is the rate-limited one.
resetKartaViewFixture();
const ui = () => const ui = () =>
page.evaluate(() => page.evaluate(() =>
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
@@ -895,6 +899,11 @@ async function main() {
const street = (await ui()).street; const street = (await ui()).street;
if (!fixtures && /No street-level imagery/.test(street.error || '')) if (!fixtures && /No street-level imagery/.test(street.error || ''))
return skip('no KartaView photo near the line'); 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.error, null, `viewer error: ${street.error}`);
assert.equal(street.providerId, 'kartaview'); assert.equal(street.providerId, 'kartaview');
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo'); assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
@@ -964,7 +973,7 @@ async function main() {
); );
if (fixtures) if (fixtures)
await step( 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 () => { async () => {
const pano = photos.find((photo) => photo.isPano); const pano = photos.find((photo) => photo.isPano);
await page.evaluate((id) => { await page.evaluate((id) => {
@@ -1004,12 +1013,56 @@ async function main() {
await uiUntil((v, was) => v.street.bearing !== was, bearing), await uiUntil((v, was) => v.street.bearing !== was, bearing),
'dragging the panorama turned its 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 else
await skipStep( await skipStep(
'fixtures only', '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(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
+23 -2
View File
@@ -342,17 +342,36 @@ export function createStreetLevelLayer({
function setProviderEnabled(providerId, on) { function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId); const entry = state.providers.get(providerId);
if (!entry) return false; if (!entry) return false;
const before = litIds();
const next = on !== false; const next = on !== false;
if (next) if (next)
for (const other of state.providers.values()) for (const other of state.providers.values())
if (other !== entry && other.on) switchProvider(other, false); if (other !== entry && other.on) switchProvider(other, false);
switchProvider(entry, next); switchProvider(entry, next);
// A failed lookup's message was about the source just switched away. sourceChanged(before);
if (!state.street.open) state.street.error = null;
notify(); notify();
return true; 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) { function switchProvider(entry, on) {
if (entry.on === on) return; if (entry.on === on) return;
entry.on = on; entry.on = on;
@@ -618,9 +637,11 @@ export function createStreetLevelLayer({
filter: state.filter, filter: state.filter,
}); });
const lit = litByParams(decoded.providers); const lit = litByParams(decoded.providers);
const before = litIds();
for (const [id, entry] of state.providers) for (const [id, entry] of state.providers)
if (id !== lit) switchProvider(entry, false); if (id !== lit) switchProvider(entry, false);
if (lit) switchProvider(state.providers.get(lit), true); if (lit) switchProvider(state.providers.get(lit), true);
sourceChanged(before);
notify(); notify();
setCoverageFilter(decoded.filter); setCoverageFilter(decoded.filter);
return true; return true;
+46
View File
@@ -897,3 +897,49 @@ test('KartaView through the core: its own swatch without Selected, its lines cou
false, 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);
});
@@ -179,7 +179,17 @@ export function createPanoramaRenderer(canvas) {
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); 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. // 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_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
@@ -205,6 +215,19 @@ export function createPanoramaRenderer(canvas) {
hasImage: () => loaded, hasImage: () => loaded,
clear() { clear() {
loaded = false; 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(); scheduleRender();
}, },
destroy() { destroy() {
@@ -140,3 +140,30 @@ test('the canvas is measured again when it draws, not only when its image arrive
runFrames(); runFrames();
assert.deepEqual([canvas.width, canvas.height], [400, 250]); 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);
});
@@ -100,6 +100,8 @@ export const SEQUENCE_PAGE_EDGE = 12;
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */ /** Equirectangular panorama sizes: a quick preview, then the sharp one. */
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]); 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]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/** A flat photo's first version, then its sharp one. */ /** A flat photo's first version, then its sharp one. */
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]); export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
@@ -12,14 +12,37 @@ import {
PHOTO_API_RETRIES, PHOTO_API_RETRIES,
PHOTO_API_FIRST_TIMEOUT_MS, PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS, PHOTO_API_TIMEOUT_MS,
PAGE_TIMEOUT_MS,
TILE_CONCURRENCY, TILE_CONCURRENCY,
} from './policy.js'; } 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) => ({ const ok = (body) => ({
ok: true, ok: true,
status: 200, status: 200,
headers: new Map(), 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()) => ({ const status = (code, headers = new Map()) => ({
ok: false, 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 * 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. * 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; let now = 0;
const stamp = () => now; const stamp = () => now;
let outcome = null; let outcome = null;
@@ -367,7 +390,7 @@ async function onMockClock(t, call) {
(value) => (outcome = { value, at: now }), (value) => (outcome = { value, at: now }),
(error) => (outcome = { error, at: now }), (error) => (outcome = { error, at: now }),
); );
while (!outcome && now < 60_000) { while (!outcome && now < until) {
await flush(); await flush();
if (outcome) break; if (outcome) break;
t.mock.timers.tick(100); 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: [] }); 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);
});
@@ -9,6 +9,7 @@ import {
IMAGE_TIMEOUT_MS, IMAGE_TIMEOUT_MS,
KARTAVIEW_PROVIDER_ID, KARTAVIEW_PROVIDER_ID,
PANO_FULL_SIZE, PANO_FULL_SIZE,
PANO_MID_SIZE,
PANO_PREVIEW_SIZE, PANO_PREVIEW_SIZE,
kartaViewPhotoUrl, kartaViewPhotoUrl,
resizedImageUrl, resizedImageUrl,
@@ -16,11 +17,23 @@ import {
const LOAD_FAILED = 'The KartaView photo could not be loaded'; 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 <img> (90 ms → 15 ms for
* 4096×2048 on a phone-class CPU).
*/
export function loadImage( export function loadImage(
url, url,
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {}, { 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) => { return new Promise((resolve, reject) => {
if (!url) return reject(new Error(LOAD_FAILED)); if (!url) return reject(new Error(LOAD_FAILED));
const image = new Image(); 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 <img>, a 360° * Viewer adapter for KartaView photos: a flat photo in an <img>, a 360°
* photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step * photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step
@@ -85,6 +121,10 @@ export function createKartaViewViewer({
let removeListeners = []; let removeListeners = [];
/** The document the viewer was built in (new canvases come from it). */ /** The document the viewer was built in (new canvases come from it). */
let documentOf = null; 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. */ /** The photo an open is fetching: ‹ › step on from it, not the old one. */
let openingId = null; let openingId = null;
/** ‹ › chose this photo: keep the panorama's look direction for it. */ /** ‹ › chose this photo: keep the panorama's look direction for it. */
@@ -199,6 +239,9 @@ export function createKartaViewViewer({
event.preventDefault?.(); event.preventDefault?.();
const lost = renderer; const lost = renderer;
renderer = null; 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 { try {
lost?.destroy(); lost?.destroy();
} catch { } catch {
@@ -213,7 +256,6 @@ export function createKartaViewViewer({
if (showing === 'pano' && photo && openingId === null) { if (showing === 'pano' && photo && openingId === null) {
const again = photo.id; const again = photo.id;
show(null); show(null);
keepViewFor = again;
openingId = again; openingId = again;
onStep(again); onStep(again);
} }
@@ -427,22 +469,34 @@ export function createKartaViewViewer({
async function upgrade(target, seq, kind) { async function upgrade(target, seq, kind) {
// A 360° photo shown flat (no WebGL) keeps its front view. // A 360° photo shown flat (no WebGL) keeps its front view.
if (kind === 'flat' && target.isPano) return; if (kind === 'flat' && target.isPano) return;
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
if (kind === 'pano' && size[0] <= panoWidth) return;
const url = const url =
kind === 'pano' kind === 'pano'
? resizedImageUrl(target.urls.pano, PANO_FULL_SIZE) ? resizedImageUrl(target.urls.pano, size)
: resizedImageUrl(target.urls.full, FLAT_FULL_SIZE); : resizedImageUrl(target.urls.full, size);
if (!url) return; if (!url) return;
try { try {
const image = await load(url, { crossOrigin: kind === 'pano' }); const image = await load(url, { crossOrigin: kind === 'pano' });
if (seq !== openSeq || photo !== target || showing !== kind) return; if (seq !== openSeq || photo !== target || showing !== kind) return;
if (kind === 'pano') renderer.setImage(image); if (kind === 'pano') {
else picture.src = image.src; if (!renderer || size[0] <= panoWidth) return;
renderer.setImage(image);
panoWidth = size[0];
} else picture.src = image.src;
requestRender(); requestRender();
} catch { } catch {
/* the first version stays */ /* 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) { async function showPano(target, seq, keepView) {
// Only the proxy can shrink a 13,000 px panorama: if it fails, the // 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. // 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), resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
{ crossOrigin: true }, { crossOrigin: true },
); );
if (seq !== openSeq || !renderer) return false; if (seq !== openSeq) return false;
if (!keepView || showing !== 'pano') // 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 }); renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
photo = target; photo = target;
// Shown first: a hidden canvas measures 0 and would be sized 1×1. // Shown first: a hidden canvas measures 0 and would be sized 1×1.
show('pano'); show('pano');
renderer.setImage(image); renderer.setImage(image);
panoWidth = 0;
return true; return true;
} }
@@ -549,6 +610,9 @@ export function createKartaViewViewer({
resize() { resize() {
renderer?.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. */ /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createKartaViewViewer } from './viewer.js'; import { createKartaViewViewer, loadImage, panoSizeFor } from './viewer.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
const PHOTOS = { 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({ function setup({
failLoads = [], failLoads = [],
rendererThrows = false, 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(poses.at(-1).bearing, 150);
assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit'); assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit');
await settle(); 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. // Drag left by a quarter of the width: the view turns right.
const frames = []; const frames = [];
globalThis.requestAnimationFrame = (callback) => { globalThis.requestAnimationFrame = (callback) => {
@@ -749,3 +761,146 @@ test('› to another 360° photo keeps the look direction; opening one afresh fa
await viewer.open('pano1'); await viewer.open('pano1');
assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead'); 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/,
);
});
+21 -2
View File
@@ -4,6 +4,9 @@
#street-level-panel, #street-level-panel,
.sl-viewer-wrap { .sl-viewer-wrap {
--sl-green: #05cb63; --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: 0.62rem;
--sl-text-small: 0.6rem; --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 { .sl-chips .data-toggle-chip.active::before {
opacity: 1; opacity: 1;
} }
@@ -623,7 +631,18 @@ button.sl-status:focus-visible {
} }
.sl-kv-viewer: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, .sl-kv-photo,
@@ -679,7 +698,7 @@ button.sl-status:focus-visible {
.sl-kv-step:hover:not(:disabled), .sl-kv-step:hover:not(:disabled),
.sl-kv-step:focus-visible { .sl-kv-step:focus-visible {
background: rgba(99, 91, 255, 0.85); background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
outline: none; outline: none;
} }