mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user