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>`),
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.
@@ -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;
+55 -2
View File
@@ -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
+23 -2
View File
@@ -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;
+46
View File
@@ -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);
});
@@ -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() {
@@ -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);
});
@@ -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]);
@@ -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);
});
@@ -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 <img> (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 <img>, 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. */
@@ -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/,
);
});
+21 -2
View File
@@ -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;
}