Files
gods-eye-view/src/sources/vectorTiles.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

439 lines
14 KiB
JavaScript

import { phaseTiming } from './phaseTiming.js';
import { tilesForBounds } from '../data/tomtomTiles.js';
import { readResponseBytesCapped, readResponseJsonCapped } from './httpBody.js';
// Marks text for i18n extraction; the UI translates these failures where it
// shows them (layer row, loading chip). Kept local because AI tool services
// import this module outside the browser.
const msg = (text) => text;
/** Validate a non-dateline geographic viewport before selecting a bounded grid. */
export function validTileBounds(box) {
return (
box &&
[box.south, box.west, box.north, box.east].every(Number.isFinite) &&
box.south >= -90 &&
box.north <= 90 &&
box.west >= -180 &&
box.east <= 180 &&
box.north > box.south &&
box.east > box.west
);
}
/**
* The origin a configured TileJSON is served from, or undefined when there is
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
* the old host: the origin check below then names the mismatch instead of
* passing it (#933).
*/
function tileJsonOrigin(tileJsonUrl) {
if (typeof tileJsonUrl !== 'string') return undefined;
try {
return new URL(tileJsonUrl).origin;
} catch {
return undefined;
}
}
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
export function createVectorTileSource({
tileJsonUrl,
template,
allowedOrigin = tileJsonOrigin(tileJsonUrl),
decode,
fetchImpl = (...args) => globalThis.fetch(...args),
maxTiles = 16,
concurrency = 4,
ttlMs = Infinity,
maxEntries = 64,
maxCacheBytes = 24 * 1024 * 1024,
maxResponseBytes = 4 * 1024 * 1024,
metadataCooldownMs = 5000,
now = Date.now,
}) {
const cache = new Map();
const active = new Set();
const flights = new Map();
let cacheBytes = 0;
let metadata = null;
let metadataError = null;
let metadataFlight = null;
let metadataRetryAt = 0;
let generation = 0;
let fetched = 0;
let activeWorkers = 0;
const waiters = [];
async function request(url, parentSignal, read) {
const controller = new AbortController();
const abort = () => controller.abort(parentSignal?.reason);
parentSignal?.throwIfAborted();
parentSignal?.addEventListener('abort', abort, { once: true });
active.add(controller);
const timer = setTimeout(
() =>
controller.abort(
new DOMException(
msg('Vector tile request timed out'),
'TimeoutError',
),
),
12_000,
);
let response;
try {
response = await fetchImpl(url, {
signal: controller.signal,
redirect: 'error',
});
if (!response.ok) {
const status = Number.isFinite(response.status)
? response.status
: null;
throw Object.assign(
new Error(
status === null
? msg('Vector tiles unavailable')
: // Shown through the 'Vector tiles unavailable (HTTP {status})' key.
`Vector tiles unavailable (HTTP ${status})`,
),
{ status },
);
}
const value = await read(response, controller.signal);
controller.signal.throwIfAborted();
return value;
} catch (error) {
// Retain request ownership until every failed read has been cancelled.
controller.abort(error);
await response?.body?.cancel().catch(() => {});
throw error;
} finally {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abort);
active.delete(controller);
}
}
async function getMetadata(signal) {
signal?.throwIfAborted();
if (metadata) return metadata;
if (metadataError && (!metadataError.retryable || now() < metadataRetryAt))
throw metadataError;
metadataError = null;
if (!metadataFlight) {
const flight = {
controller: new AbortController(),
users: 0,
promise: null,
};
const metadataStart = performance.now();
metadataFlight = flight;
flight.promise = (
template
? Promise.resolve({ tiles: [template] })
: request(
tileJsonUrl,
flight.controller.signal,
(res, requestSignal) =>
readResponseJsonCapped(res, 256 * 1024, requestSignal),
)
)
.then((value) => {
flight.controller.signal.throwIfAborted();
const url = value?.tiles?.[0];
if (
typeof url !== 'string' ||
!['{z}', '{x}', '{y}'].every((token) => url.includes(token))
)
throw Object.assign(new Error('Invalid vector tile metadata'), {
retryable: false,
});
let parsed;
try {
parsed = new URL(
url.replace('{z}', '0').replace('{x}', '0').replace('{y}', '0'),
allowedOrigin,
);
} catch {
throw Object.assign(new Error('Invalid vector tile origin'), {
retryable: false,
});
}
if (
parsed.origin !== allowedOrigin ||
parsed.username ||
parsed.password
)
throw Object.assign(new Error('Invalid vector tile origin'), {
retryable: false,
});
if (!template)
phaseTiming('tilejson-fetch', metadataStart, {
source: allowedOrigin,
});
metadata = { ...value, template: url };
return metadata;
})
.catch((error) => {
if (flight.controller.signal.aborted || error.name === 'AbortError')
throw error;
metadataError = Object.assign(
new Error(msg('Vector tile metadata unavailable'), {
cause: error,
}),
{
status: error.status ?? null,
name: error.name === 'TimeoutError' ? 'TimeoutError' : 'Error',
retryable:
error.retryable !== false && !(error instanceof SyntaxError),
},
);
metadataRetryAt =
now() + Math.min(30_000, Math.max(0, metadataCooldownMs));
throw metadataError;
})
.finally(() => {
if (metadataFlight === flight) metadataFlight = null;
});
}
const flight = metadataFlight;
flight.users += 1;
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const cleanup = () => signal?.removeEventListener('abort', abort);
flight.promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
},
);
}).finally(() => {
// Each caller owns its wait; only the last departing caller cancels shared metadata.
flight.users -= 1;
if (!flight.users && metadataFlight === flight) {
flight.controller.abort();
metadataFlight = null;
}
});
}
async function acquire(signal) {
signal?.throwIfAborted();
if (activeWorkers >= concurrency) {
await new Promise((resolve, reject) => {
const waiter = {
resolve: () => {
signal?.removeEventListener('abort', abort);
resolve();
},
};
const abort = () => {
const i = waiters.indexOf(waiter);
if (i >= 0) waiters.splice(i, 1);
reject(signal.reason);
};
signal?.addEventListener('abort', abort, { once: true });
waiters.push(waiter);
});
} else activeWorkers += 1;
}
function release() {
const waiter = waiters.shift();
if (waiter) waiter.resolve();
else activeWorkers -= 1;
}
function subscribe(flight, signal, key) {
signal?.throwIfAborted();
flight.users++;
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
flight.promise
.then(resolve, reject)
.finally(() => signal?.removeEventListener('abort', abort));
}).finally(() => {
if (--flight.users === 0 && flights.get(key) === flight) {
flights.delete(key);
flight.controller.abort();
}
});
}
async function getTile(tile, meta, signal, epoch) {
signal?.throwIfAborted();
const key = `${meta.template}:${tile.z}/${tile.x}/${tile.y}`;
const hit = cache.get(key);
if (hit && now() - hit.at < ttlMs) {
cache.delete(key);
cache.set(key, hit);
return hit.value;
}
let flight = flights.get(key);
if (!flight) {
flight = { controller: new AbortController(), users: 0 };
const ownedSignal = flight.controller.signal;
flights.set(key, flight);
flight.promise = (async () => {
await acquire(ownedSignal);
try {
ownedSignal.throwIfAborted();
if (epoch !== generation)
throw new DOMException('Source cleared', 'AbortError');
fetched++;
const url = meta.template
.replace('{z}', tile.z)
.replace('{x}', tile.x)
.replace('{y}', tile.y);
const fetchStart = performance.now();
const bytes = await request(url, ownedSignal, (res, requestSignal) =>
readResponseBytesCapped(res, maxResponseBytes, requestSignal),
);
phaseTiming('tile-fetch', fetchStart, {
source: allowedOrigin,
key,
bytes: bytes.byteLength,
});
const decodeStart = performance.now();
const value = decode(bytes, tile.z, tile.x, tile.y);
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
ownedSignal.throwIfAborted();
// Body-based decoded-storage estimate; never stringify geometry on the load path.
const size = bytes.byteLength * 4;
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
cache.set(key, { at: now(), value, size });
cacheBytes += size;
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).size;
cache.delete(oldest);
}
}
return value;
} finally {
release();
}
})().finally(() => {
if (flights.get(key) === flight) flights.delete(key);
});
}
return subscribe(flight, signal, key);
}
return {
getMetadata,
async fetchBounds(
box,
{ zoom, signal, onTile, tiles: selectedTiles } = {},
) {
if (
!validTileBounds(box) ||
!Number.isInteger(zoom) ||
zoom < 0 ||
zoom > 14
)
throw new TypeError('Invalid tile viewport');
signal?.throwIfAborted();
const epoch = generation;
const metaStart = performance.now();
const meta = await getMetadata(signal);
phaseTiming('tilejson', metaStart, { source: allowedOrigin });
const candidates = selectedTiles
? selectedTiles.slice()
: tilesForBounds(box, zoom, { maxTiles: maxTiles + 1 });
if (
candidates.some(
(t) =>
t.z !== zoom ||
!Number.isInteger(t.x) ||
!Number.isInteger(t.y) ||
t.x < 0 ||
t.y < 0 ||
t.x >= 2 ** zoom ||
t.y >= 2 ** zoom,
)
)
throw new TypeError('Invalid tile selection');
const cx = candidates.reduce((n, t) => n + t.x, 0) / candidates.length;
const cy = candidates.reduce((n, t) => n + t.y, 0) / candidates.length;
if (!selectedTiles)
candidates.sort(
(a, b) =>
Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy),
);
const limited = candidates.length > maxTiles;
// Refuse over-wide views instead of silently sampling a northwest strip.
if (limited)
throw Object.assign(
new Error(msg('Zoom in for vector tile coverage')),
{
retryable: false,
code: 'TILE_VIEW_TOO_WIDE',
},
);
let index = 0;
const results = new Array(candidates.length);
await Promise.all(
Array.from(
{ length: Math.min(concurrency, candidates.length) },
async () => {
while (index < candidates.length) {
const i = index++;
try {
results[i] = {
value: await getTile(candidates[i], meta, signal, epoch),
tile: candidates[i],
};
signal?.throwIfAborted();
onTile?.(results[i].value, candidates[i]);
} catch (error) {
results[i] = { error };
}
}
},
),
);
signal?.throwIfAborted();
if (epoch !== generation)
throw new DOMException('Source cleared', 'AbortError');
const good = results.filter((result) => !result.error);
if (!good.length && results.length) throw results[0].error;
return {
tiles: good.map((result) => result.value),
loadedTiles: good.map((result) => result.tile),
partial: good.length !== results.length,
limited,
};
},
getStats: () => ({
tilesFetched: fetched,
cacheEntries: cache.size,
cacheBytes,
}),
clear() {
generation += 1;
metadataFlight?.controller.abort();
metadataFlight = null;
metadata = null;
metadataError = null;
metadataRetryAt = 0;
for (const flight of flights.values()) flight.controller.abort();
flights.clear();
for (const controller of active) controller.abort();
cache.clear();
cacheBytes = 0;
},
};
}