mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
97a1a667de
commit
c99eb93174
12 files changed
+512
-26
No files matched your search
+11
-6
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user