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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-13 20:08:23 +01:00
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);
});
+3 -2
View File
@@ -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