mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(street-level): IC8 review of KartaView — cones on the right page, pano sizing
P1s from the correctness review, each with a regression test: - Cones showed the wrong page of a long sequence (cache keyed by sequence; no re-centre after a close or a same-drive line click): KartaView always reselects, and re-centres whenever a photo of the selected sequence opens on another page. - A 360° photo's first frame was one colour: the canvas was sized while hidden (1×1). It is shown first, and re-measured every frame. P2/P3s: a storage timeout or 5xx is not remembered as archived (only 403/404/409/410 are); dense z13 tiles draw up to 1,600 lines (a third of downtown Detroit was cut); decoded tiles are kept 10 min; a timed-out tile is asked once more; pages are 250 photos and the newest page request goes first; a photo far past deleted ones, or unlisted at a page edge, still finds its neighbours; a step that gets no answer is not the end; a lost WebGL context redraws the photo, and a failed one is retried on the next mount; the first flat image comes from the proxy (KartaView's own can be 1.7 MB); fileurl fallbacks must be https storage hosts; oversized answers are refused; keyboard (Shift+← →, ↑ ↓, + −) and pinch look around a panorama; focus stays in the viewer when a step button disables. Docs: share links with 0.m.0, keyless count, CURRENT-STATE matched to the code. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1a2fbda464
commit
bd0bbf5134
13 files changed
+699
-117
No files matched your search
@@ -30,7 +30,10 @@ import {
|
||||
/** Photos kept in memory by id (sequence pages fill it). */
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 8;
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
const TILE_CACHE_SIZE = 24;
|
||||
const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** z14 near the street, z13 a little higher, nothing above. */
|
||||
export function kartaViewZoomFor(heightM) {
|
||||
@@ -62,6 +65,30 @@ function createLru(limit) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
|
||||
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
|
||||
* a pan back or a zoom across z13/z14 would fetch them all again.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||
function pageOf(index) {
|
||||
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
|
||||
@@ -184,9 +211,21 @@ export function createKartaViewProvider({
|
||||
async function homePage(photo) {
|
||||
let number = pageOf(photo.sequenceIndex ?? 0);
|
||||
const index = photo.sequenceIndex ?? 0;
|
||||
let page = await loadPage(photo.sequenceId, number);
|
||||
if (!page.photos.length && number > 1) {
|
||||
// Past the end (many photos deleted): find the last page by halving.
|
||||
let low = 1;
|
||||
let high = number - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
if ((await loadPage(photo.sequenceId, middle)).photos.length)
|
||||
low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
number = low;
|
||||
}
|
||||
const seen = new Set();
|
||||
let page = null;
|
||||
while (!seen.has(number) && seen.size < 6) {
|
||||
while (!seen.has(number) && seen.size < 8) {
|
||||
seen.add(number);
|
||||
page = await loadPage(photo.sequenceId, number);
|
||||
if (page.photos.some((p) => p.id === photo.id)) return page;
|
||||
@@ -208,11 +247,18 @@ export function createKartaViewProvider({
|
||||
async function windowOf(photo) {
|
||||
const home = await homePage(photo);
|
||||
const list = home.photos;
|
||||
const at = list.findIndex((p) => p.id === photo.id);
|
||||
const found = list.findIndex((p) => p.id === photo.id);
|
||||
// A photo no page lists sits where its index would.
|
||||
const at =
|
||||
found !== -1
|
||||
? found
|
||||
: list.filter(
|
||||
(p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0),
|
||||
).length;
|
||||
const parts = [list];
|
||||
if (at !== -1 && at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
if (at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
|
||||
if (at !== -1 && at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
|
||||
if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos);
|
||||
if (parts.length === 1) return { home: home.page, photos: list };
|
||||
const byId = new Map(parts.flat().map((p) => [p.id, p]));
|
||||
@@ -224,19 +270,39 @@ export function createKartaViewProvider({
|
||||
};
|
||||
}
|
||||
|
||||
/** Photo id → whether its file answers (archived ones do not), per session. */
|
||||
/**
|
||||
* Photo id → whether its file is there (archived ones are not). Only
|
||||
* a definite answer is kept: a storage hiccup is asked again next
|
||||
* time, and meanwhile the photo is tried. A probe is shared, so it
|
||||
* runs to its own timeout rather than any one caller's abort.
|
||||
*/
|
||||
const probes = createLru(PHOTO_CACHE_SIZE);
|
||||
function showable(photo, { signal } = {}) {
|
||||
function showable(photo) {
|
||||
if (!photo?.urls?.view) return Promise.resolve(false);
|
||||
let probe = probes.get(photo.id);
|
||||
if (!probe) {
|
||||
probe = source.probeImage(photo.urls.view, { signal });
|
||||
probe = source.probeImage(photo.urls.view).then(
|
||||
(answer) => {
|
||||
if (answer === null) probes.delete(photo.id);
|
||||
return answer !== false;
|
||||
},
|
||||
() => {
|
||||
probes.delete(photo.id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
probes.set(photo.id, probe);
|
||||
probe.catch(() => probes.delete(photo.id));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
// Decoded coverage tiles, kept a while: panning back asks nothing.
|
||||
const tiles = createTileCache({
|
||||
load: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
});
|
||||
const fetchTile = (tile, options) => tiles.get(tile, options);
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
* end. Archived photos are skipped, a few at most.
|
||||
@@ -269,8 +335,7 @@ export function createKartaViewProvider({
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
fetchTile,
|
||||
zoomFor: kartaViewZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.line,
|
||||
@@ -307,20 +372,28 @@ export function createKartaViewProvider({
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
/** ‹ › open through the core (loading, errors, cones), camera still. */
|
||||
function openStepped(photoId) {
|
||||
context.actions.openImage(photoId, { frame: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* ‹ › open through the core (loading, errors, the sequence's cones),
|
||||
* without moving the camera; the cones re-centre once ‹ › walk off the
|
||||
* page they were drawn from.
|
||||
* The cones show one page of a sequence: once a photo of the selected
|
||||
* sequence opens on another page (‹ ›, a cone, a line), re-centre them.
|
||||
*/
|
||||
async function openStepped(photoId) {
|
||||
const opened = await context.actions.openImage(photoId, {
|
||||
frame: false,
|
||||
});
|
||||
const photo = photos.get(photoId);
|
||||
if (!opened || !photo?.sequenceId) return;
|
||||
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
||||
async function recentreCones(photo) {
|
||||
if (!photo?.sequenceId) return;
|
||||
if (state.sequence.selectedId !== photo.sequenceId) return;
|
||||
const drawn = drawnPage;
|
||||
if (drawn?.sequenceId !== photo.sequenceId) return;
|
||||
const window = await windowOf(photo).catch(() => null);
|
||||
if (window && window.home !== drawnPage.page)
|
||||
// Still the drawn page and the latest photo once the page is in.
|
||||
if (
|
||||
window &&
|
||||
drawnPage === drawn &&
|
||||
window.home !== drawn.page &&
|
||||
anchors.get(photo.sequenceId) === photo
|
||||
)
|
||||
cones.select(photo.sequenceId, { force: true });
|
||||
}
|
||||
|
||||
@@ -332,6 +405,7 @@ export function createKartaViewProvider({
|
||||
},
|
||||
step,
|
||||
onStep: openStepped,
|
||||
onShown: recentreCones,
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
@@ -414,7 +488,9 @@ export function createKartaViewProvider({
|
||||
return false;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) => cones.select(sequenceId),
|
||||
// Never from the cache: it is keyed by sequence, the cones by page.
|
||||
selectSequence: (sequenceId) =>
|
||||
cones.select(sequenceId, { force: true }),
|
||||
clearSequence: () => {
|
||||
cones.clearSelection();
|
||||
drawnPage = null;
|
||||
|
||||
@@ -136,7 +136,8 @@ export function createPanoramaRenderer(canvas) {
|
||||
let frame = 0;
|
||||
const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG };
|
||||
|
||||
function resize() {
|
||||
/** Match the drawing buffer to the canvas's laid-out size. */
|
||||
function syncSize() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
|
||||
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
|
||||
@@ -144,12 +145,18 @@ export function createPanoramaRenderer(canvas) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
syncSize();
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame = 0;
|
||||
if (gl.isContextLost()) return;
|
||||
// Every frame: the canvas may have been laid out since the last one.
|
||||
syncSize();
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
@@ -37,8 +37,9 @@ export const COVERAGE_MAX_HEIGHT_M = 6_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
// Chained ways are short: a dense z13 tile holds ~1,300.
|
||||
maxSequences: 9_000,
|
||||
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
|
||||
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
|
||||
maxSequences: 14_400,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 2.5,
|
||||
moveDebounceMs: 320,
|
||||
@@ -65,8 +66,10 @@ export const TILE_CONCURRENCY = 3;
|
||||
export const PAGE_CONCURRENCY = 1;
|
||||
export const TILE_TIMEOUT_MS = 20_000;
|
||||
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
||||
/** A sequence page is about a megabyte of JSON. */
|
||||
/** A sequence page is about 650 kB of JSON. */
|
||||
export const PAGE_TIMEOUT_MS = 30_000;
|
||||
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
|
||||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
/** Whether a photo file is there (not archived): headers only. */
|
||||
@@ -83,17 +86,18 @@ export const NEAREST_SEARCH_ZOOM = 18;
|
||||
|
||||
/**
|
||||
* Photos of a sequence fetched at once, around the open one. Records are
|
||||
* ~2.4 kB each, so a page is ~1.2 MB; the cones and ‹ › use it. Pages are in
|
||||
* ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in
|
||||
* photo-id order, which only roughly follows the sequence index.
|
||||
*/
|
||||
export const SEQUENCE_PAGE_SIZE = 500;
|
||||
export const SEQUENCE_PAGE_SIZE = 250;
|
||||
/** Within this many photos of a page's edge, its neighbour page joins it. */
|
||||
export const SEQUENCE_PAGE_EDGE = 12;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's sharp version, after the 1280 px one is on screen. */
|
||||
/** A flat photo's first version, then its sharp one. */
|
||||
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
|
||||
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
|
||||
/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
@@ -149,24 +153,46 @@ function positionOf(record) {
|
||||
|
||||
const STORAGE_HOST = /^storage\d+$/;
|
||||
|
||||
/** A KartaView photo-storage address, or null for anything else. */
|
||||
function storageUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return url.protocol === 'https:' &&
|
||||
/^storage\d+\.openstreetcam\.org$/.test(url.hostname)
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo file's address. The API's `fileurl*` fields always name storage1,
|
||||
* but a photo whose `storage` is another host (storage2…) is only there, so
|
||||
* the address is built from `storage` and the relative `filepath*`.
|
||||
* the address is built from `storage` and the relative `filepath*`. A
|
||||
* `fileurl*` fallback must itself be on KartaView's storage.
|
||||
*/
|
||||
function fileUrl(record, pathKey, urlKey) {
|
||||
const path = record[pathKey];
|
||||
if (STORAGE_HOST.test(record.storage || '') && path)
|
||||
return `https://${record.storage}.openstreetcam.org/files/photo/${path}`;
|
||||
return record[urlKey] || null;
|
||||
return storageUrl(
|
||||
`https://${record.storage}.openstreetcam.org/files/photo/${path}`,
|
||||
);
|
||||
return storageUrl(record[urlKey]);
|
||||
}
|
||||
|
||||
/** A storage URL with its `{{sizeprefix}}` placeholder filled. */
|
||||
function sized(record, prefix) {
|
||||
const url = fileUrl(record, 'filepath', 'fileurl') || '';
|
||||
return url.includes('{{sizeprefix}}')
|
||||
? url.replace('{{sizeprefix}}', prefix)
|
||||
: null;
|
||||
const fill = (value) =>
|
||||
typeof value === 'string' && value.includes('{{sizeprefix}}')
|
||||
? value.replace('{{sizeprefix}}', prefix)
|
||||
: null;
|
||||
const path = fill(record.filepath);
|
||||
return fileUrl(
|
||||
{ ...record, filepath: path, fileurl: fill(record.fileurl) },
|
||||
'filepath',
|
||||
'fileurl',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,18 @@ test('photo files come from the storage host the record names, not the one its U
|
||||
Buffer.from(link.split('/').at(-1), 'base64url').toString(),
|
||||
photo.urls.full,
|
||||
);
|
||||
// A fallback address must be on KartaView's storage, over https.
|
||||
for (const fileurlLTh of [
|
||||
'https://tracker.example/x.jpg',
|
||||
'http://storage1.openstreetcam.org/a.jpg',
|
||||
'javascript:alert(1)',
|
||||
]) {
|
||||
const hostile = normalizePhoto(
|
||||
record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }),
|
||||
);
|
||||
assert.notEqual(hostile.urls.view, fileurlLTh);
|
||||
assert.doesNotMatch(String(hostile.urls.view), /tracker|javascript|^http:/);
|
||||
}
|
||||
// An unexpected storage value falls back to the URLs as given.
|
||||
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
|
||||
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createKartaViewProvider, kartaViewZoomFor } from './index.js';
|
||||
import {
|
||||
createKartaViewProvider,
|
||||
createTileCache,
|
||||
kartaViewZoomFor,
|
||||
} from './index.js';
|
||||
import { SEQUENCE_PAGE_SIZE } from './policy.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
@@ -46,7 +50,8 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
calls.photo.push(id);
|
||||
for (const [seq, indexes] of Object.entries(sequences))
|
||||
for (const index of indexes)
|
||||
if (`${seq}-${index}` === id) return photoRecord(id, { seq, index });
|
||||
if (`${seq}-${index}` === id)
|
||||
return photoRecord(id, { seq, index, metres: index * 5 });
|
||||
throw new Error('KartaView has no such photo');
|
||||
},
|
||||
getSequencePhotos: async (seq, { page, itemsPerPage }) => {
|
||||
@@ -58,7 +63,7 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
);
|
||||
return {
|
||||
photos: rows.map((index) =>
|
||||
photoRecord(`${seq}-${index}`, { seq, index }),
|
||||
photoRecord(`${seq}-${index}`, { seq, index, metres: index * 5 }),
|
||||
),
|
||||
hasMore: page * itemsPerPage < indexes.length,
|
||||
};
|
||||
@@ -161,8 +166,9 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing
|
||||
await instance.viewer.open(id);
|
||||
assert.deepEqual(source.calls.photo, [], 'the lookup filled the cache');
|
||||
assert.equal(
|
||||
loads[0],
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/near.jpg',
|
||||
Buffer.from(loads[0].split('/').at(-1), 'base64url').toString(),
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/near.jpg',
|
||||
'the proxy shrinks the photo on its real storage host',
|
||||
);
|
||||
const none = setup({ nearest: [photoRecord('far', { metres: 80 })] });
|
||||
assert.equal(
|
||||
@@ -321,3 +327,131 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a
|
||||
'a record with no file is not a photo',
|
||||
);
|
||||
});
|
||||
|
||||
/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */
|
||||
async function withCones(t, env) {
|
||||
const Cesium = await import('cesium');
|
||||
const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
};
|
||||
t.after(() => (globalThis.document = saved));
|
||||
let collection = null;
|
||||
env.instance.init({
|
||||
scene: {
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
primitives: { add: (c) => (collection = c), remove() {} },
|
||||
preRender: { addEventListener: () => () => {} },
|
||||
},
|
||||
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
|
||||
});
|
||||
return () => {
|
||||
const indexes = [];
|
||||
for (let i = 0; i < collection.length; i++)
|
||||
indexes.push(Number(collection.get(i).id.split('-').at(-1)));
|
||||
return [Math.min(...indexes), Math.max(...indexes)];
|
||||
};
|
||||
}
|
||||
|
||||
test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
const env = await mounted({
|
||||
sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) },
|
||||
});
|
||||
const cones = await withCones(t, env);
|
||||
const settleLong = async () => {
|
||||
for (let i = 0; i < 40; i++) await settle();
|
||||
};
|
||||
await env.instance.viewer.open('s-10');
|
||||
await env.instance.selectSequence('s');
|
||||
await settleLong();
|
||||
assert.ok(cones()[1] < size, `first page: ${cones()}`);
|
||||
// A line click further along the same drive: the core does not reselect.
|
||||
await env.instance.viewer.open(`s-${size * 4 + 10}`);
|
||||
await settleLong();
|
||||
assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`);
|
||||
// Closed and reopened on another page: not the sequence's cached cones.
|
||||
env.instance.clearSequence();
|
||||
await env.instance.viewer.open(`s-${size * 2 + 10}`);
|
||||
await env.instance.selectSequence('s');
|
||||
await settleLong();
|
||||
assert.ok(
|
||||
cones()[0] <= size * 2 + 10 && cones()[1] >= size * 2 + 10,
|
||||
`around the photo: ${cones()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a storage hiccup is not remembered as an archived photo', async () => {
|
||||
const env = setup({ nearest: [photoRecord('near', { metres: 3 })] });
|
||||
let answer = null;
|
||||
env.source.probeImage = async () => answer;
|
||||
// No answer: the photo is tried rather than called archived.
|
||||
assert.equal(
|
||||
await env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
'near',
|
||||
);
|
||||
answer = false;
|
||||
await assert.rejects(
|
||||
env.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
/archived/,
|
||||
'asked again, and now it is gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('a photo far past a run of deleted photos still finds its page and neighbours', async () => {
|
||||
const indexes = [
|
||||
...Array.from({ length: 1000 }, (_, i) => i),
|
||||
...Array.from({ length: 20 }, (_, i) => 6000 + i),
|
||||
];
|
||||
const { instance, poses, next, prev } = await mounted({
|
||||
sequences: { s: indexes },
|
||||
});
|
||||
await instance.viewer.open('s-6005');
|
||||
for (let i = 0; i < 12; i++) await settle();
|
||||
assert.equal(next.disabled, false);
|
||||
assert.equal(prev.disabled, false);
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), 's-6006');
|
||||
});
|
||||
|
||||
test('coverage tiles are kept for a while: panning back asks KartaView nothing', async () => {
|
||||
let now = 0;
|
||||
const asked = [];
|
||||
const tiles = createTileCache({
|
||||
load: async (tile) => {
|
||||
asked.push(`${tile.z}/${tile.x}/${tile.y}`);
|
||||
return { sequences: [], overview: [] };
|
||||
},
|
||||
size: 2,
|
||||
ttlMs: 1000,
|
||||
now: () => now,
|
||||
});
|
||||
const a = { x: 1, y: 2, z: 14 };
|
||||
const b = { x: 2, y: 2, z: 14 };
|
||||
const c = { x: 3, y: 2, z: 14 };
|
||||
const first = await tiles.get(a, {});
|
||||
assert.equal(await tiles.get(a, {}), first, 'the same decoded tile');
|
||||
await tiles.get(b, {});
|
||||
await tiles.get(c, {});
|
||||
await tiles.get(a, {});
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
['14/1/2', '14/2/2', '14/3/2', '14/1/2'],
|
||||
'oldest dropped',
|
||||
);
|
||||
now = 2000;
|
||||
await tiles.get(a, {});
|
||||
assert.equal(asked.length, 5, 'stale after the TTL');
|
||||
// A failed load is not kept.
|
||||
const failing = createTileCache({
|
||||
load: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
size: 2,
|
||||
ttlMs: 1000,
|
||||
});
|
||||
await assert.rejects(failing.get(a, {}), /did not answer/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
KARTAVIEW_API_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
@@ -25,6 +26,8 @@ export class KartaViewSourceError extends Error {
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
|
||||
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
|
||||
|
||||
/** Resolves after `ms`, or rejects when `signal` aborts first. */
|
||||
function wait(ms, signal) {
|
||||
@@ -50,13 +53,18 @@ function retryAfterSeconds(response) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/** A first-in, first-out gate letting at most `limit` tasks run at once. */
|
||||
export function createLimiter(limit) {
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = queue.shift();
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
@@ -111,7 +119,7 @@ export function createKartaViewSource({
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
@@ -135,6 +143,11 @@ export function createKartaViewSource({
|
||||
`KartaView answered HTTP ${response.status}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_RESPONSE_BYTES)
|
||||
throw new KartaViewSourceError('KartaView sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
@@ -203,19 +216,27 @@ export function createKartaViewSource({
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
return tileLimiter.run(
|
||||
() =>
|
||||
getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`;
|
||||
const once = () =>
|
||||
tileLimiter.run(
|
||||
() => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }),
|
||||
{ signal },
|
||||
);
|
||||
try {
|
||||
return await once();
|
||||
} catch (error) {
|
||||
// A cold dense tile can outlast the timeout; KartaView has it ready
|
||||
// by the second ask.
|
||||
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
|
||||
return once();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file can be fetched. Archived photos answer 409 (and
|
||||
* a HEAD says 200 regardless), so this GETs and stops at the headers.
|
||||
* Whether a photo file is there: true, false when it is gone (archived
|
||||
* photos answer 409, and a HEAD says 200 regardless), or null when the
|
||||
* storage did not say (timeout, network, 5xx). It GETs and stops at the
|
||||
* headers.
|
||||
*/
|
||||
async probeImage(url, { signal } = {}) {
|
||||
if (!url) return false;
|
||||
@@ -229,10 +250,11 @@ export function createKartaViewSource({
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
return response.ok;
|
||||
if (response.ok) return true;
|
||||
return GONE_STATUSES.includes(response.status) ? false : null;
|
||||
} catch {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
|
||||
@@ -282,8 +282,67 @@ test('an image probe reads only the status: an archived photo (409) is not showa
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
// No answer is not an answer: the caller may try again later.
|
||||
assert.equal(
|
||||
await offline.probeImage('https://x.openstreetcam.org/a.jpg'),
|
||||
false,
|
||||
null,
|
||||
);
|
||||
const busy = createKartaViewSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 502 }),
|
||||
});
|
||||
assert.equal(
|
||||
await busy.probeImage('https://x.openstreetcam.org/a.jpg'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a cold tile that times out is asked once more', async () => {
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
return ok({ lines: [] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('an absurdly large answer is refused before it is parsed', async () => {
|
||||
let parsed = false;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
|
||||
json: async () => {
|
||||
parsed = true;
|
||||
return { lines: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
|
||||
assert.equal(parsed, false);
|
||||
});
|
||||
|
||||
test('sequence pages run one at a time, the newest first', async () => {
|
||||
const order = [];
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
const page = new URL(url).searchParams.get('page');
|
||||
order.push(page);
|
||||
if (page === '1') await gate;
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
const pages = ['1', '2', '3'].map((page) =>
|
||||
source.getSequencePhotos('9', { page, itemsPerPage: 250 }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
release();
|
||||
await Promise.all(pages);
|
||||
assert.deepEqual(order, ['1', '3', '2']);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './panorama.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
@@ -50,6 +51,7 @@ export function loadImage(
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
@@ -59,6 +61,7 @@ export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
@@ -150,12 +153,18 @@ export function createKartaViewViewer({
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) button.disabled = true;
|
||||
if (photo === target && button && !id) disable(button);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Disable a step button, keeping keyboard focus in the viewer. */
|
||||
function disable(button) {
|
||||
if (button.ownerDocument?.activeElement === button) root?.focus?.();
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
showing = kind;
|
||||
if (picture) picture.hidden = kind !== 'flat';
|
||||
@@ -180,6 +189,26 @@ export function createKartaViewViewer({
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
|
||||
* with it: drop the renderer, and show the photo again on a new one.
|
||||
*/
|
||||
function onContextLost(event) {
|
||||
event.preventDefault?.();
|
||||
const lost = renderer;
|
||||
renderer = null;
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
if (showing === 'pano' && photo) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
adapter.open(again).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
@@ -194,13 +223,14 @@ export function createKartaViewViewer({
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
nextId = null;
|
||||
// Not the end of the sequence, just no answer: the press is lost.
|
||||
return;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) button.disabled = true;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
@@ -209,20 +239,42 @@ export function createKartaViewViewer({
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
|
||||
const pointers = new Map();
|
||||
let pinch = null;
|
||||
|
||||
function pinchSpan() {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || event.button !== 0) return;
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
if (pointers.size === 2) {
|
||||
drag = null;
|
||||
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
|
||||
return;
|
||||
}
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
canvas.classList.add('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
if (pointers.has(event.pointerId))
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && pointers.size === 2 && renderer) {
|
||||
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.id || !renderer) return;
|
||||
const { fov } = drag.view;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
@@ -236,13 +288,15 @@ export function createKartaViewViewer({
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano') return;
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
@@ -256,9 +310,30 @@ export function createKartaViewViewer({
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
if (turn && event.shiftKey && pano) {
|
||||
event.preventDefault();
|
||||
stepBy(event.key === 'ArrowLeft' ? -1 : 1);
|
||||
const { yaw, fov } = renderer.getView();
|
||||
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
|
||||
emitSoon();
|
||||
} else if (turn) {
|
||||
event.preventDefault();
|
||||
stepBy(turn);
|
||||
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
|
||||
event.preventDefault();
|
||||
const { pitch, fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
|
||||
});
|
||||
emitSoon();
|
||||
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +352,7 @@ export function createKartaViewViewer({
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. Drag to look around a 360° photo; ← and → step along the sequence',
|
||||
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
@@ -310,11 +385,14 @@ export function createKartaViewViewer({
|
||||
listen(canvas, 'pointerup', onPointerUp);
|
||||
listen(canvas, 'pointercancel', onPointerUp);
|
||||
listen(canvas, 'wheel', onWheel, { passive: false });
|
||||
listen(canvas, 'webglcontextlost', onContextLost);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
@@ -355,25 +433,36 @@ export function createKartaViewViewer({
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) return false;
|
||||
if (seq !== openSeq || !renderer) return false;
|
||||
if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
renderer.setImage(image);
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq) {
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
const proxied = target.isPano
|
||||
? null
|
||||
: resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE);
|
||||
const sources = [proxied, target.urls.view].filter(Boolean);
|
||||
let image;
|
||||
try {
|
||||
image = await load(target.urls.view, { crossOrigin: false });
|
||||
} catch (error) {
|
||||
// No 1280 px rendition: the proxy can shrink the full-size photo.
|
||||
const fallback = resizedImageUrl(target.urls.full, FLAT_FULL_SIZE);
|
||||
if (target.isPano || !fallback || seq !== openSeq) throw error;
|
||||
image = await load(fallback, { crossOrigin: false });
|
||||
let failure = null;
|
||||
for (const url of sources) {
|
||||
try {
|
||||
image = await load(url, { crossOrigin: false });
|
||||
break;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
if (seq !== openSeq) return false;
|
||||
}
|
||||
}
|
||||
if (!image) throw failure || new Error(LOAD_FAILED);
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
photo = target;
|
||||
@@ -396,6 +485,7 @@ export function createKartaViewViewer({
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
@@ -404,6 +494,7 @@ export function createKartaViewViewer({
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
|
||||
@@ -51,12 +51,15 @@ const PHOTOS = {
|
||||
},
|
||||
};
|
||||
|
||||
function fakeRenderer() {
|
||||
function fakeRenderer(canvas) {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
return {
|
||||
images: [],
|
||||
hiddenAtSetImage: [],
|
||||
setImage(image) {
|
||||
this.images.push(image.src);
|
||||
// A hidden canvas measures 0×0: sizing it then gives a 1×1 buffer.
|
||||
this.hiddenAtSetImage.push(canvas?.hidden);
|
||||
},
|
||||
setView(next) {
|
||||
Object.assign(view, next);
|
||||
@@ -70,7 +73,12 @@ function fakeRenderer() {
|
||||
};
|
||||
}
|
||||
|
||||
function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
function setup({
|
||||
failLoads = [],
|
||||
rendererThrows = false,
|
||||
steps = {},
|
||||
failSteps = false,
|
||||
} = {}) {
|
||||
const loads = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
@@ -79,7 +87,10 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
if (!PHOTOS[id]) throw new Error('no such photo');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null,
|
||||
step: async (photo, delta) => {
|
||||
if (failSteps) throw new Error('KartaView did not answer in time');
|
||||
return steps[`${photo.id}:${delta}`] ?? null;
|
||||
},
|
||||
// The core opens what ‹ › chose; here the adapter is opened directly.
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
@@ -91,9 +102,9 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
throw new Error('The KartaView photo could not be loaded');
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
createRenderer: (canvas) => {
|
||||
if (rendererThrows) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer();
|
||||
const renderer = fakeRenderer(canvas);
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
@@ -149,7 +160,11 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
},
|
||||
);
|
||||
assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//);
|
||||
assert.equal(loads[0].url, PHOTOS.flat1.urls.view);
|
||||
// The proxy's 1,280 px version first: KartaView's own can be 1.7 MB.
|
||||
assert.match(
|
||||
loads[0].url,
|
||||
/^https:\/\/cdn\.kartaview\.org\/.*rs:fit:1280:1280/,
|
||||
);
|
||||
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
|
||||
await settle();
|
||||
assert.match(
|
||||
@@ -162,12 +177,13 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
assert.equal(picture.style.objectFit, 'cover');
|
||||
});
|
||||
|
||||
test('a missing 1280 px rendition falls back to the proxy-shrunk full photo', async () => {
|
||||
const { viewer, host, loads } = setup({ failLoads: ['/lth/'] });
|
||||
test('a failing image proxy falls back to the photo on KartaView storage', async () => {
|
||||
const { viewer, host, loads } = setup({ failLoads: ['cdn.kartaview.org'] });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
assert.match(loads[1].url, /rs:fit:2560:2560/);
|
||||
assert.equal(loads[1].url, PHOTOS.flat1.urls.view);
|
||||
assert.equal(parts(host).picture.hidden, false);
|
||||
assert.equal(parts(host).picture.src, PHOTOS.flat1.urls.view);
|
||||
});
|
||||
|
||||
test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => {
|
||||
@@ -291,3 +307,120 @@ test('a newer open wins; close hides the photo; unmount tears down the panorama'
|
||||
assert.equal(renderers[0].destroyed, true);
|
||||
await assert.rejects(viewer.open('flat1'), /not mounted/);
|
||||
});
|
||||
|
||||
test('a panorama is put on screen before its image is sized into the canvas', async () => {
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
await viewer.open('pano1');
|
||||
await settle();
|
||||
assert.deepEqual(renderers[0].hiddenAtSetImage, [false, false]);
|
||||
});
|
||||
|
||||
test('a step that gets no answer is not the end of the sequence', async () => {
|
||||
const { viewer, host, poses } = setup({ failSteps: true });
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { next } = parts(host);
|
||||
next.disabled = false;
|
||||
next.fire('click');
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(next.disabled, false, 'still enabled: try again');
|
||||
assert.equal(poses.at(-1).imageId, 'flat1');
|
||||
});
|
||||
|
||||
test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => {
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
t.after(() => delete globalThis.requestAnimationFrame);
|
||||
const { viewer, host, renderers, stepped } = setup({
|
||||
steps: { 'pano1:1': 'flat1' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { root } = parts(host);
|
||||
const view = () => renderers[0].getView();
|
||||
root.fire('keydown', { key: 'ArrowRight', shiftKey: true });
|
||||
assert.ok(view().yaw > 0, 'turned right');
|
||||
root.fire('keydown', { key: 'ArrowUp' });
|
||||
assert.ok(view().pitch > 0, 'looked up');
|
||||
const before = view().fov;
|
||||
root.fire('keydown', { key: '+' });
|
||||
assert.ok(view().fov < before, 'zoomed in');
|
||||
root.fire('keydown', { key: '-' });
|
||||
root.fire('keydown', { key: '-' });
|
||||
assert.ok(view().fov > before, 'zoomed out');
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.deepEqual(stepped, [], 'none of those stepped');
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps');
|
||||
});
|
||||
|
||||
test('two fingers pinch the panorama’s zoom', async () => {
|
||||
const { viewer, host, renderers } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
const { canvas } = parts(host);
|
||||
const before = renderers[0].getView().fov;
|
||||
canvas.fire('pointerdown', {
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointerdown', {
|
||||
pointerId: 2,
|
||||
pointerType: 'touch',
|
||||
clientX: 140,
|
||||
clientY: 100,
|
||||
});
|
||||
canvas.fire('pointermove', {
|
||||
pointerId: 2,
|
||||
pointerType: 'touch',
|
||||
clientX: 180,
|
||||
clientY: 100,
|
||||
});
|
||||
assert.ok(renderers[0].getView().fov < before, 'spread zooms in');
|
||||
canvas.fire('pointerup', { pointerId: 1 });
|
||||
canvas.fire('pointerup', { pointerId: 2 });
|
||||
});
|
||||
|
||||
test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => {
|
||||
const { viewer, host, renderers, poses } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
parts(host).canvas.fire('webglcontextlost');
|
||||
assert.equal(renderers[0].destroyed, true);
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(renderers.length, 2, 'a new renderer');
|
||||
assert.equal(poses.at(-1).imageId, 'pano1');
|
||||
assert.equal(parts(host).canvas.hidden, false);
|
||||
});
|
||||
|
||||
test('a WebGL failure only lasts until the viewer is mounted again', async () => {
|
||||
let fail = true;
|
||||
const built = [];
|
||||
const viewer = createKartaViewViewer({
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => ({ src: url }),
|
||||
createRenderer: (canvas) => {
|
||||
if (fail) throw new Error('WebGL is unavailable');
|
||||
const renderer = fakeRenderer(canvas);
|
||||
built.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
assert.equal(parts(host).picture.hidden, false, 'front view');
|
||||
viewer.unmount();
|
||||
fail = false;
|
||||
const again = fakeHost();
|
||||
await viewer.mount(again);
|
||||
await viewer.open('pano1');
|
||||
assert.equal(built.length, 1);
|
||||
assert.equal(parts(again).canvas.hidden, false);
|
||||
});
|
||||
@@ -22,8 +22,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [defaultOn] Default true.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -67,6 +67,7 @@ export function filtersOf(provider) {
|
||||
* @property {() => void} resize
|
||||
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
|
||||
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
|
||||
* @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide.
|
||||
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||
*
|
||||
* @typedef {object} StreetPose
|
||||
|
||||
Reference in new issue
Block a user