Files
gods-eye-view/src/layers/streetLevel/providers/kartaview/source.js
T
German KhokhlovandClaude Opus 5.5 00704ddc89 i18n: fix review findings and polish ru/es strings
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]>
2026-10-15 23:49:52 +04:00

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,
};
},
};
}