mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
470 lines
15 KiB
JavaScript
470 lines
15 KiB
JavaScript
import {
|
|
IMAGE_PROBE_TIMEOUT_MS,
|
|
MAX_RESPONSE_BYTES,
|
|
KARTAVIEW_API_HOST,
|
|
MASK_ATTEMPTS,
|
|
MASK_MAX_BYTES,
|
|
MASK_RETRY_WAIT_MS,
|
|
MASK_SLOW_MESSAGE,
|
|
MASK_TIMEOUT_MS,
|
|
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_FIRST_TIMEOUT_MS,
|
|
PHOTO_API_TIMEOUT_MS,
|
|
TILE_CONCURRENCY,
|
|
TILE_TIMEOUT_MS,
|
|
photoFilterParams,
|
|
} from './policy.js';
|
|
import { msg } from '../../../../i18n/index.js';
|
|
|
|
/**
|
|
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
|
* `timedOut` when it did not answer in time (the one failure worth asking
|
|
* again at once).
|
|
*/
|
|
export class KartaViewSourceError extends Error {
|
|
constructor(
|
|
message,
|
|
{ status = 0, retryAfterSec = null, timedOut = false } = {},
|
|
) {
|
|
super(message);
|
|
this.name = 'KartaViewSourceError';
|
|
this.status = status;
|
|
this.retryAfterSec = retryAfterSec;
|
|
this.timedOut = timedOut;
|
|
}
|
|
}
|
|
|
|
// Failure texts are English data: the Street Level panel translates them.
|
|
const NO_ANSWER = msg('KartaView did not answer in time — try again');
|
|
const UNREACHABLE = msg('KartaView could not be reached — check the network');
|
|
const UNREADABLE = msg('KartaView sent an answer that could not be read');
|
|
/** 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) {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal?.aborted) return reject(signal.reason);
|
|
const timer = setTimeout(done, ms);
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(signal.reason);
|
|
};
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
function done() {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Seconds from a Retry-After header, or null. */
|
|
function retryAfterSeconds(response) {
|
|
const header = response.headers?.get?.('retry-after');
|
|
const value = header == null || header === '' ? NaN : Number(header);
|
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
}
|
|
|
|
/**
|
|
* 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 } = lifo
|
|
? queue.pop()
|
|
: queue.shift();
|
|
if (signal?.aborted) {
|
|
reject(signal.reason);
|
|
continue;
|
|
}
|
|
running++;
|
|
Promise.resolve()
|
|
.then(task)
|
|
.then(resolve, reject)
|
|
.finally(() => {
|
|
running--;
|
|
next();
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
run(task, { signal } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const item = { task, resolve, reject, signal };
|
|
queue.push(item);
|
|
signal?.addEventListener(
|
|
'abort',
|
|
() => {
|
|
const at = queue.indexOf(item);
|
|
if (at === -1) return;
|
|
queue.splice(at, 1);
|
|
reject(signal.reason);
|
|
},
|
|
{ once: true },
|
|
);
|
|
next();
|
|
});
|
|
},
|
|
get pending() {
|
|
return running + queue.length;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* KartaView's public API, browser-side and keyless (it allows any origin).
|
|
* 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, maskRetryMs?: number}} [options]
|
|
*/
|
|
export function createKartaViewSource({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
apiHost = KARTAVIEW_API_HOST,
|
|
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
|
maskRetryMs = MASK_RETRY_WAIT_MS,
|
|
} = {}) {
|
|
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
|
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
|
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
|
|
|
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
|
function getJson(url, { signal, timeoutMs }) {
|
|
return fetchBody(url, { signal, timeoutMs, as: 'json' });
|
|
}
|
|
|
|
/**
|
|
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
|
|
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
|
|
* timeout.
|
|
*/
|
|
async function fetchBody(
|
|
url,
|
|
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
|
|
) {
|
|
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(
|
|
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
|
|
),
|
|
timeoutMs,
|
|
);
|
|
let response = null;
|
|
let read = false;
|
|
try {
|
|
response = await fetchImpl(url, { signal: controller.signal });
|
|
if (response.status === 429)
|
|
throw new KartaViewSourceError(
|
|
msg('KartaView is rate-limiting requests'),
|
|
{
|
|
status: 429,
|
|
retryAfterSec: retryAfterSeconds(response) ?? 2,
|
|
},
|
|
);
|
|
// English data with a number: the Street Level panel and the layer
|
|
// panel show it through the 'KartaView answered HTTP {status}' entry.
|
|
if (!response.ok)
|
|
throw new KartaViewSourceError(
|
|
`KartaView answered HTTP ${response.status}`,
|
|
{ status: response.status },
|
|
);
|
|
const length = Number(response.headers?.get?.('content-length'));
|
|
const tooMuch = () =>
|
|
new KartaViewSourceError(msg('KartaView sent too much data'), {
|
|
status: response.status,
|
|
});
|
|
if (length > maxBytes) throw tooMuch();
|
|
// KartaView answers chunked, without a length: count while reading.
|
|
const reader = response.body?.getReader?.();
|
|
if (!reader) {
|
|
read = true;
|
|
try {
|
|
if (as === 'bytes')
|
|
return new Uint8Array(await response.arrayBuffer());
|
|
return await response.json();
|
|
} catch (error) {
|
|
if (controller.signal.aborted) throw error;
|
|
throw new KartaViewSourceError(UNREADABLE, {
|
|
status: response.status,
|
|
});
|
|
}
|
|
}
|
|
read = true;
|
|
const chunks = [];
|
|
let bytes = 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
bytes += value.byteLength;
|
|
if (bytes > maxBytes) {
|
|
reader.cancel().catch(() => {});
|
|
throw tooMuch();
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
const body = new Uint8Array(bytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
body.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
if (as === 'bytes') return body;
|
|
try {
|
|
return JSON.parse(new TextDecoder().decode(body));
|
|
} catch {
|
|
throw new KartaViewSourceError(UNREADABLE, {
|
|
status: response.status,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (signal?.aborted) throw signal.reason;
|
|
if (controller.signal.aborted) throw controller.signal.reason;
|
|
if (error instanceof KartaViewSourceError) throw error;
|
|
// fetch rejects on no network, a blocked request or a refused CORS check.
|
|
throw new KartaViewSourceError(UNREACHABLE);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', onAbort);
|
|
// An answer turned away unread (429, an error, too long) still holds
|
|
// its connection, one of the six the browser has: let it go.
|
|
if (response && !read) {
|
|
controller.abort();
|
|
if (response.body && !response.body.locked)
|
|
response.body.cancel?.().catch?.(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
firstTimeoutMs = PHOTO_API_FIRST_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 gate.run(
|
|
// A slow first answer is usually quick when asked again.
|
|
() =>
|
|
getJson(url, {
|
|
signal,
|
|
timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs,
|
|
}),
|
|
{ signal },
|
|
);
|
|
const code = Number(payload?.status?.apiCode);
|
|
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
|
// Shown through 'KartaView refused the request (code {code})'.
|
|
if (Number.isFinite(code) && (code < 600 || code > 603))
|
|
throw new KartaViewSourceError(
|
|
`KartaView refused the request (code ${code})`,
|
|
{ status: Number(payload?.status?.httpCode) || 0 },
|
|
);
|
|
return payload?.result ?? {};
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
const timedOut = error?.timedOut === true;
|
|
if (timedOut && timeouts++ < 1) continue;
|
|
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
|
if (error?.status === 429)
|
|
throw new KartaViewSourceError(
|
|
msg(
|
|
'KartaView is rate-limiting requests — try again in a moment',
|
|
),
|
|
{ status: 429, retryAfterSec: error.retryAfterSec },
|
|
);
|
|
throw error;
|
|
}
|
|
await wait(
|
|
Math.min(
|
|
PHOTO_API_RETRY_MAX_MS,
|
|
Math.max(
|
|
retryBaseMs * 2 ** attempt,
|
|
(error.retryAfterSec ?? 0) * 1000,
|
|
),
|
|
),
|
|
signal,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
|
async getTile({ x, y, z }, { 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?.timedOut !== true) throw error;
|
|
return once();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
|
|
* (KartaView filters only its PNG tiles). It draws a filtered tile on
|
|
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
|
|
* so a timeout or a 5xx is asked again a few seconds later.
|
|
*/
|
|
async getTileMask({ x, y, z }, query, { signal } = {}) {
|
|
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
|
|
for (let attempt = 1; ; attempt++) {
|
|
try {
|
|
// Outside the gate between tries: a wait holds no connection.
|
|
return await tileLimiter.run(
|
|
() =>
|
|
fetchBody(url, {
|
|
signal,
|
|
timeoutMs: MASK_TIMEOUT_MS,
|
|
as: 'bytes',
|
|
maxBytes: MASK_MAX_BYTES,
|
|
}),
|
|
{ signal },
|
|
);
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
const drawing =
|
|
error?.timedOut === true || [502, 503, 504].includes(error?.status);
|
|
if (!drawing) throw error;
|
|
if (attempt >= MASK_ATTEMPTS)
|
|
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
|
|
timedOut: true,
|
|
});
|
|
await wait(maskRetryMs, signal);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
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 });
|
|
if (response.ok) return true;
|
|
return GONE_STATUSES.includes(response.status) ? false : null;
|
|
} catch {
|
|
if (signal?.aborted) throw signal.reason;
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', onAbort);
|
|
// The body is not needed: drop it.
|
|
controller.abort();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Photo records around a point, nearest first; with a resolved imagery
|
|
* `filter`, only those of its projection uploaded since its cut-off.
|
|
*/
|
|
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
|
|
const result = await photoApi(
|
|
'/2.0/photo/',
|
|
{
|
|
lat: lat.toFixed(7),
|
|
lng: lon.toFixed(7),
|
|
zoomLevel: String(NEAREST_SEARCH_ZOOM),
|
|
itemsPerPage: String(NEAREST_LIMIT),
|
|
...photoFilterParams(filter),
|
|
},
|
|
{ signal },
|
|
);
|
|
return Array.isArray(result.data) ? result.data : [];
|
|
},
|
|
|
|
/** One photo record. */
|
|
async getPhoto(photoId, { signal } = {}) {
|
|
const result = await photoApi(
|
|
`/2.0/photo/${encodeURIComponent(photoId)}`,
|
|
null,
|
|
{ signal },
|
|
);
|
|
if (!result.data || typeof result.data !== 'object')
|
|
throw new KartaViewSourceError(msg('KartaView has no such photo'));
|
|
return result.data;
|
|
},
|
|
|
|
/** How many photos a sequence has, or null if KartaView does not say. */
|
|
async getSequenceCount(sequenceId, { signal } = {}) {
|
|
const result = await photoApi(
|
|
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
|
|
null,
|
|
{ signal },
|
|
);
|
|
const count = Number(result.data?.countActivePhotos);
|
|
return Number.isFinite(count) && count > 0 ? count : null;
|
|
},
|
|
|
|
/** A page of a sequence's photos in capture order (1-based `page`). */
|
|
async getSequencePhotos(
|
|
sequenceId,
|
|
{ page, itemsPerPage },
|
|
{ signal } = {},
|
|
) {
|
|
const result = await photoApi(
|
|
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
|
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
|
{ signal },
|
|
{
|
|
gate: pageLimiter,
|
|
timeoutMs: PAGE_TIMEOUT_MS,
|
|
firstTimeoutMs: PAGE_TIMEOUT_MS,
|
|
},
|
|
);
|
|
return {
|
|
photos: Array.isArray(result.data) ? result.data : [],
|
|
hasMore: result.hasMoreData === true,
|
|
};
|
|
},
|
|
};
|
|
}
|