mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(street-level): KartaView QA round — archived photos, connection budget, paging, ‹ ›
From two agentic QA passes (UX and failure modes): - A line click opens the nearest photo that can be shown: archived photos (409; a HEAD says 200) and records without files are skipped, a few at most, else 'KartaView's photos here are archived'. ‹ › skip them too. - api.kartaview.org is HTTP/1.1 (six connections): tiles take three, lookups two, sequence pages one, so a lookup never times out behind tiles; a timed-out lookup is asked once more; waits are capped at 10 s. - Sequence pages follow photo ids, not the index: a photo's page is found by search, a neighbour page joins near an edge, so ‹ › neither skip at page edges nor loop past gaps. Pages are 500 photos (~1.2 MB). - ‹ › open through the core without framing: loading, errors and cones behave as for any open; presses step on from the photo on its way; the ends disable up front. - Cones ignore the hidden IMAGERY / SINCE filters; no 'Selected' swatch for a source whose lines never highlight; 'no KartaView photos in view'; an unreadable tile is not cached as empty; a 0,0 fix is no photo; a failed lookup's error goes when another source is lit; the link goes through the image proxy; a stalled panorama shows its front view after 15 s instead of 60. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
310984ff0b
commit
454e261256
16 files changed
+683
-179
No files matched your search
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
KARTAVIEW_API_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PAGE_CONCURRENCY,
|
||||
PAGE_TIMEOUT_MS,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
PHOTO_API_RETRY_BASE_MS,
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -19,7 +24,7 @@ export class KartaViewSourceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer — check the network and try again';
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
|
||||
/** Resolves after `ms`, or rejects when `signal` aborts first. */
|
||||
function wait(ms, signal) {
|
||||
@@ -92,8 +97,11 @@ export function createLimiter(limit) {
|
||||
|
||||
/**
|
||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
||||
* Coverage tiles are not rate-limited; photo lookups are, so they queue two
|
||||
* at a time and retry a 429.
|
||||
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||
* at most three, photo lookups two and sequence pages one, so a lookup never
|
||||
* waits behind slow tiles or pages. The photo API answers 429 to more than
|
||||
* about three requests at once; a 429 (and a lookup that timed out) is
|
||||
* retried.
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
@@ -102,6 +110,8 @@ export function createKartaViewSource({
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY);
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
@@ -137,32 +147,52 @@ export function createKartaViewSource({
|
||||
}
|
||||
}
|
||||
|
||||
/** A photo-API call through the limiter, retrying 429s with backoff. */
|
||||
async function photoApi(path, params, { signal } = {}) {
|
||||
/**
|
||||
* A photo-API call through `gate`, retrying 429s with backoff and, once, a
|
||||
* call that timed out.
|
||||
*/
|
||||
async function photoApi(
|
||||
path,
|
||||
params,
|
||||
{ signal } = {},
|
||||
{ gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {},
|
||||
) {
|
||||
const query = params ? `?${new URLSearchParams(params)}` : '';
|
||||
const url = `${apiHost}${path}${query}`;
|
||||
let timeouts = 0;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const payload = await limiter.run(
|
||||
() => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }),
|
||||
const payload = await gate.run(
|
||||
() => getJson(url, { signal, timeoutMs }),
|
||||
{ signal },
|
||||
);
|
||||
const code = Number(payload?.status?.apiCode);
|
||||
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
||||
if (Number.isFinite(code) && (code < 600 || code > 603))
|
||||
throw new KartaViewSourceError(
|
||||
String(
|
||||
payload?.status?.apiMessage || 'KartaView refused the request',
|
||||
).trim(),
|
||||
`KartaView refused the request (code ${code})`,
|
||||
{ status: Number(payload?.status?.httpCode) || 0 },
|
||||
);
|
||||
return payload?.result ?? {};
|
||||
} catch (error) {
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error;
|
||||
if (signal?.aborted) throw error;
|
||||
const timedOut = error?.message === NO_ANSWER && error.status === 0;
|
||||
if (timedOut && timeouts++ < 1) continue;
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
||||
if (error?.status === 429)
|
||||
throw new KartaViewSourceError(
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
{ status: 429, retryAfterSec: error.retryAfterSec },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await wait(
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
Math.min(
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
@@ -173,10 +203,42 @@ export function createKartaViewSource({
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
});
|
||||
return tileLimiter.run(
|
||||
() =>
|
||||
getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async probeImage(url, { signal } = {}) {
|
||||
if (!url) return false;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
return response.ok;
|
||||
} catch {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// The body is not needed: drop it.
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
|
||||
/** Photo records around a point, nearest first. */
|
||||
@@ -216,6 +278,7 @@ export function createKartaViewSource({
|
||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
||||
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
||||
{ signal },
|
||||
{ gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
return {
|
||||
photos: Array.isArray(result.data) ? result.data : [],
|
||||
|
||||
Reference in new issue
Block a user