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

@@ -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);