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]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-15 23:49:52 +04:00
1 parent 8029747b7e
commit 00704ddc89
212 files changed
+9871 -909

No files matched your search

+1 -1
View File
@@ -276,7 +276,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.stale = Boolean(state.lastUpdate);
setAlprStatus(
'unavailable',
error?.message || 'Camera source temporarily unavailable',
error?.message || msg('Camera source temporarily unavailable'),
);
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
+1 -1
View File
@@ -139,7 +139,7 @@ export function createAlprTileSource({
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error('Camera coverage unavailable');
throw new Error(msg('Camera coverage unavailable'));
if (
box.east < west ||
box.west > east ||
+3 -1
View File
@@ -1,3 +1,4 @@
import { msg } from '../../i18n/index.js';
import { CITY_BY_ID } from './registry.js';
export function createIngestion({
@@ -188,7 +189,8 @@ export function createIngestion({
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
// English data (stats reach the voice tools); the panel translates it.
layerState._error = msg('GBFS status update failed');
}
}),
);
+3 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { GBFS_CITY_REGISTRY } from './registry.js';
import {
ACTIVATION_EXIT_ALTITUDE_M,
@@ -172,7 +173,8 @@ export function createViewport({ state: layerState, services, parts, source }) {
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
// English data (stats reach the voice tools); the panel translates it.
layerState._error = msg('GBFS fetch error');
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import { setLocale, t } from '../../i18n/index.js';
// The exact string the server sends (server/providers/cyclones.js:8).
const SERVER_COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const time = '2026-09-16T03:00:00.000Z';
const storm = {
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
};
const snapshot = (storms, extra = {}) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: SERVER_COVERAGE,
reason: null,
...extra,
});
function harness(feed) {
const rendering = {
async setSnapshot() {
return true;
},
setSelection() {},
getFocusSphere: () => ({ radius: 500000 }),
clear() {},
destroy() {},
getDiagnostics: () => ({ entities: 0, timerActive: false }),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: false }),
openLink: () => {},
});
layer.init({ camera: { flyToBoundingSphere() {} } });
layer.attachShellServices({ runNavigation: (fn) => fn() });
return layer;
}
const translated = () => t(SERVER_COVERAGE);
test('K-35a: es, server snapshot without storms -> coverage line in Spanish', async () => {
setLocale('es');
try {
assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation');
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('K-35b: es, server snapshot with a storm -> coverage line in Spanish', async () => {
setLocale('es');
try {
const layer = harness({ getSnapshot: async () => snapshot([storm]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('K-35c: es, unavailable without reason -> fallback status not the English literal', async () => {
setLocale('es');
try {
const layer = harness({
getSnapshot: async () =>
snapshot([], { unavailable: true, reason: null }),
});
layer.enable();
await layer.update();
const status = layer.getRowControls().summary.status;
assert.notEqual(
status,
'Cyclone advisories unavailable',
`fallback shown in English: ${status}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('K-35: en coverage and fallback status read as before', async () => {
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
assert.ok(layer.getRowControls().info.endsWith(SERVER_COVERAGE));
layer.destroy();
const down = harness({
getSnapshot: async () => snapshot([], { unavailable: true, reason: null }),
});
down.enable();
await down.update();
assert.equal(
down.getRowControls().summary.status,
'Cyclone advisories unavailable',
);
down.destroy();
});
+21 -6
View File
@@ -14,7 +14,13 @@ import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.js';
import {
formatUtcDayMonth,
t,
tn,
msg,
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
@@ -26,6 +32,15 @@ const utc = (value) =>
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
);
// The server sends the same English coverage (server/providers/cyclones.js);
// data stays English and is translated here, where it is shown.
const coverageText = (coverage) =>
!coverage || coverage === COVERAGE ? t(COVERAGE) : translateKnown(coverage);
const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
// Reason the server gives with an unavailable snapshot, translated when shown.
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
const CLASSIFICATION_NAMES = Object.freeze({
PTC: msg('Potential tropical cyclone'),
HU: msg('Hurricane'),
@@ -238,7 +253,7 @@ export function createCyclonesLayer({
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
error = next.reason || ADVISORIES_UNAVAILABLE;
return true;
}
const applied = await rendering.setSnapshot(next, {
@@ -263,7 +278,7 @@ export function createCyclonesLayer({
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
error = cause?.message || ADVISORIES_UNAVAILABLE;
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
@@ -332,7 +347,7 @@ export function createCyclonesLayer({
})
: t('Track/cone unavailable'));
const status =
error ||
errorText(error) ||
(snapshot?.stale
? t('Cached advisory · stale source')
: loading
@@ -435,8 +450,8 @@ export function createCyclonesLayer({
]
: [],
info: storm
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${coverageText(snapshot.coverage)}`
: `${detail}${status ? '\n' + status : ''}\n${coverageText(snapshot?.coverage)}`,
infoTitle: t(
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
),
+37 -4
View File
@@ -18,7 +18,8 @@
import * as Cesium from 'cesium';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber } from '../../i18n/index.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
@@ -36,6 +37,36 @@ export const DIRECTIONS_MODES = Object.freeze({
});
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
@@ -471,7 +502,7 @@ export function pointerBlockedMessage(owner, which) {
const known = POINTER_TOOL_EXITS[id];
if (!known)
return t('{name} is active — turn {name} off, then set {endpoint}', {
name: id,
name: translateKnown(pointerOwnerName(id)),
endpoint,
});
return t('{name} is active — {leave}, then set {endpoint}', {
@@ -1118,7 +1149,9 @@ export function createDirectionsLayer({ services, source }) {
payload?.error === 'no route found'
? t('No route found between A and B')
: payload?.error
? t('Routing failed: {error}', { error: payload.error })
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
return;
}
@@ -1139,7 +1172,7 @@ export function createDirectionsLayer({ services, source }) {
? t('Routing timed out')
: error?.name === 'TypeError'
? t('Routing unavailable — no response from the routing service')
: error?.message || t('Routing unavailable');
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
if (_routeAbort === controller) _routeAbort = null;
+20 -4
View File
@@ -1,3 +1,7 @@
// A source module (no browser globals, see check:boundaries), so no i18n
// import: failures stay English here and the layer translates them on show.
const msg = (text) => text;
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
@@ -19,15 +23,27 @@ export function createDirectionsSource({
});
const payload = await response.json();
signal?.throwIfAborted();
// Failures keep their English message and carry the HTTP status and the
// server's refusal text, so the layer can show them in the language.
if (response.status === 429) {
throw new Error(
const refusal =
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
? payload.error
: null;
throw Object.assign(
new Error(
refusal
? `${refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment'),
),
{ status: 429, refusal },
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
throw Object.assign(
new Error(`Routing unavailable (HTTP ${response.status})`),
{ status: response.status },
);
return payload;
},
};
+5 -1
View File
@@ -11,6 +11,10 @@ import {
export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js';
// A portable package (no i18n): the error stays English data and the layer
// panel shows it translated.
const msg = (text) => text;
/** Own one earthquake display and its refresh lifecycle. */
export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
@@ -154,7 +158,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:Earthquakes] Fetch error:', e);
_lastError = e?.message || 'Earthquake source unavailable';
_lastError = e?.message || msg('Earthquake source unavailable');
return false;
} finally {
if (_request === request) _request = null;
+85
View File
@@ -0,0 +1,85 @@
// K-34: AIS degradation chip text and the traffic "SIMULATED — <reason>" text
// must be in the UI language (they stayed English in es before the fix);
// English keeps its wording.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import { deriveAisFeedError } from '../data/aisLiveVessels.js';
import { createModel } from './traffic/model.js';
test('K-34: AIS degraded-feed reasons are localized in es', () => {
setLocale('es');
try {
const cases = [
[
{ status: 'down', reconnectAttempt: 3 },
'feed down — retrying slowly (attempt 3)',
],
[{ status: 'missing-key' }, 'AISSTREAM_API_KEY not set'],
[
{ status: 'stale', silentForMs: 90000 },
'feed silent 90s — no AIS data',
],
[{ status: 'auth-failed' }, 'API key rejected — check AISSTREAM_API_KEY'],
[{ status: 'connecting' }, 'connecting to feed…'],
[{ status: 'closed' }, 'feed disconnected'],
];
for (const [payload, english] of cases) {
// The layer panel runs stats.error through translateKnown(); emulate it.
const shown = translateKnown(deriveAisFeedError(payload, 0));
assert.notEqual(shown, english, `status ${payload.status} still English`);
}
} finally {
setLocale('en');
}
});
test('K-34: traffic "SIMULATED — <reason>" is localized in es', () => {
setLocale('es');
try {
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
const degraded = trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
});
const shown = translateKnown(degraded.error);
assert.notEqual(shown, 'SIMULATED — TomTom daily budget reached');
assert.ok(!shown.startsWith('SIMULATED'), `still English: ${shown}`);
} finally {
setLocale('en');
}
});
test('K-34: English degraded-feed wording is unchanged', () => {
setLocale('en');
assert.equal(
deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0),
'feed down — retrying slowly (attempt 3)',
);
assert.equal(
deriveAisFeedError({ status: 'reconnecting' }, 0),
'reconnecting to feed…',
);
assert.equal(
deriveAisFeedError({ status: 'missing-key' }, 0),
'AISSTREAM_API_KEY not set',
);
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
assert.equal(
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
}).error,
'SIMULATED — TomTom daily budget reached',
);
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
layerState,
@@ -72,7 +73,8 @@ export function createIngestion({
)
return;
console.warn(`[Data:${id}] FIRMS live load failed:`, error);
layerState._error = 'live feed unavailable';
// English data (stats reach the voice tools); the panel translates it.
layerState._error = msg('live feed unavailable');
} finally {
if (layerState.request === request) {
layerState.request = null;
+8 -2
View File
@@ -1,5 +1,9 @@
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
// Marks English the Layers panel translates when it shows the error. Kept
// local: this module stays free of browser globals (package boundaries).
const msg = (text) => text;
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
export function createIngestion({
feed,
@@ -43,7 +47,7 @@ export function createIngestion({
feed._retryAt = 0;
feed._lastError = sourceStale
? sourceAgeMs == null
? 'Source snapshot time unavailable'
? msg('Source snapshot time unavailable')
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
feed._lastSource = snapshot.source;
@@ -77,7 +81,9 @@ export function createIngestion({
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
+19
View File
@@ -1,4 +1,19 @@
import { t } from '../../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from '../../data/installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
/**
* An installation's card title: a mapped name as it is; the English label
* the data gives an unnamed feature ("Firing range", "Military area") in the
* UI language. The record keeps the English name.
* @param {string} name
* @returns {string} Empty when the record has no name.
*/
export function installationTitle(name) {
const text = String(name ?? '');
return UNNAMED_NAMES.has(text.trim()) ? t(text.trim()) : text;
}
/**
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
@@ -19,6 +34,10 @@ export function installationClassLabel(klass) {
return t('MILITARY LAND', { context: 'installation class' });
case 'base':
return t('BASE', { context: 'installation class' });
case 'barracks':
return t('BARRACKS', { context: 'installation class' });
case 'training_area':
return t('TRAINING AREA', { context: 'installation class' });
case 'places_candidate':
return t('PLACES CANDIDATE', { context: 'installation class' });
case 'installation':
@@ -0,0 +1,36 @@
// K-50: every class the bundled OSM names pack can put on a record has a
// catalog entry, so the selected card shows it in the UI language.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { setLocale, getCatalog } from '../../i18n/index.js';
import { installationClassLabel } from './classLabel.js';
const pack = JSON.parse(
readFileSync(
new URL(
'../../data/local_data/osm_military_names/names.json',
import.meta.url,
),
'utf8',
),
);
setLocale('en');
const englishLabel = Object.fromEntries(
pack.classes.map((c) => [c, installationClassLabel(c)]),
);
for (const locale of ['es', 'ru']) {
test(`every names-pack class is in the ${locale} catalog (K-50)`, () => {
const catalog = getCatalog(locale);
const missing = pack.classes.filter(
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
);
assert.deepEqual(
missing,
[],
`no ${locale} entry for: ${missing.join(', ')}`,
);
});
}
+26 -45
View File
@@ -1,57 +1,38 @@
import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import { describeInstallationFeedback } from '../../data/installationFeedback.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t, msg } from '../../i18n/index.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so
// its copy stays English there. getStats is the browser presentation
// boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': msg(
'Zoom in to search mapped installations',
),
'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass timed out': msg('Overpass timed out'),
'Overpass could not complete the query': msg(
'Overpass could not complete the query',
),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
function localizeInstallationFeedback(message) {
if (typeof message !== 'string' || !message) return message;
const key = INSTALLATION_FEEDBACK_KEYS[message];
if (key) return t(key);
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('{reason} — retrying in {seconds}s', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
// node/browser graph (the HUD summary in loadingFeedback.js reads the same
// module), so it hands back English marked with msg() plus the values;
// getStats is the browser presentation boundary that translates them.
function localizeInstallationFeedback(feedback) {
if (feedback.text !== undefined) {
const values = {};
if (feedback.reason !== undefined) values.reason = t(feedback.reason);
if (feedback.seconds !== undefined) values.seconds = feedback.seconds;
return t(feedback.text, values);
}
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
const { count, km } = feedback;
if (km) {
const kmText = formatNumber(km);
return count === 0
? t('No mapped sites within {km} km of the contact', { km: kmText })
: tn(
count,
'{count} mapped site within {km} km of the contact',
'{count} mapped sites within {km} km of the contact',
{ km: kmText },
);
}
return message;
return count === 0
? t('No mapped sites in view')
: tn(count, '{count} mapped site in view', '{count} mapped sites in view');
}
export function createControls({ state: layerState, services, parts, source }) {
@@ -274,7 +255,7 @@ export function createControls({ state: layerState, services, parts, source }) {
retrying: layerState.loading && Boolean(layerState.failureReason),
failureReason: layerState.failureReason,
statusMessage: localizeInstallationFeedback(
installationFeedback({
describeInstallationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
+3 -2
View File
@@ -1,5 +1,6 @@
import { militaryOsmKey } from '../../data/militaryNames.js';
import { isUnavailableCapability } from '../../sources/capability.js';
import { msg } from '../../i18n/index.js';
/** Keep named point coverage until its matching polygon takes over. */
export function retainNamedHandoff(previous, records, box) {
const named = new Map(
@@ -255,7 +256,7 @@ export function createIngestion({
payload.status === 'stale'
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
: saturated
? 'Too many mapped sites in view to list them all'
? msg('Too many mapped sites in view to list them all')
: placesError,
);
parts.rendering.renderRecords();
@@ -291,7 +292,7 @@ export function createIngestion({
layerState.failureReason = error?.failureReason || 'unavailable';
setInstallationStatus(
'unavailable',
error?.message || 'Installation context unavailable',
error?.message || msg('Installation context unavailable'),
);
if (!isUnavailableCapability(error))
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../../i18n/index.js';
import { createControls } from './controls.js';
function stats(state) {
const layerState = { records: [], enabled: true, ...state };
return createControls({
state: layerState,
services: {},
parts: {},
source: {},
}).methods.getStats();
}
for (const locale of ['es', 'ru']) {
test(`${locale}: installation feedback is in the UI language`, () => {
setLocale(locale);
const empty = stats({ status: 'empty', lastUpdate: 1 }).statusMessage;
assert.notEqual(empty, 'No mapped sites in view');
const found = stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage;
assert.notEqual(found, '2 mapped sites in view');
const tiles = stats({
status: 'unavailable',
failureReason: 'tiles_unavailable',
}).statusMessage;
assert.notEqual(tiles, 'Map tiles temporarily unavailable');
const retry = stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage;
assert.doesNotMatch(retry, /^Mapped names temporarily unavailable/);
for (const text of [
'Too many mapped sites in view to list them all',
'Installation context unavailable',
]) {
assert.notEqual(translateKnown(text), text);
}
});
}
test('en: installation feedback keeps its English wording', () => {
setLocale('en');
assert.equal(
stats({ status: 'empty', lastUpdate: 1 }).statusMessage,
'No mapped sites in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
records: [{ kind: 'installation' }],
}).statusMessage,
'1 mapped site in view',
);
assert.equal(
stats({
status: 'ready',
lastUpdate: 1,
wide: true,
coverage: { kind: 'subject', radiusM: 25000 },
records: [{ kind: 'installation' }, { kind: 'installation' }],
}).statusMessage,
'2 mapped sites within 25 km of the contact',
);
assert.equal(
stats({
status: 'unavailable',
failureReason: 'names_unavailable',
retryAt: Date.now() + 5000,
}).statusMessage,
'Mapped names temporarily unavailable — retrying in 5s',
);
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import { installationClassLabel, installationTitle } from './classLabel.js';
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
@@ -163,7 +163,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
id: record.id,
source: LAYER_ID,
position,
title: record.name,
title: installationTitle(record.name),
priority: record.areaM2 || 0,
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
}),
@@ -183,7 +183,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
]
: [];
marker.entry.position = position;
marker.entry.title = record.name;
marker.entry.title = installationTitle(record.name);
marker.entry.priority = record.areaM2 || 0;
entries.push(marker.entry);
ordered.push(marker);
+2 -2
View File
@@ -1,4 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import { installationClassLabel, installationTitle } from './classLabel.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
@@ -272,7 +272,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || t('MAPPED INSTALLATION'),
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
details: [
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
import { UNNAMED_MILITARY_AREA } from '../../sources/openFreeMap.js';
import { setLocale } from '../../i18n/index.js';
import { installationTitle } from './classLabel.js';
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
// K-33: unnamed installations get an English label in data ("Firing range",
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
// The data stays English; the map card title reads in the UI language.
const { records } = normalizeMilitaryInstallations({
elements: [
{ type: 'node', id: 1, lat: 1, lon: 1, tags: { military: 'range' } },
{
type: 'way',
id: 2,
center: { lat: 2, lon: 2 },
tags: { landuse: 'military' },
},
{ type: 'node', id: 3, lat: 3, lon: 3, tags: { military: 'airfield' } },
{ type: 'node', id: 4, lat: 4, lon: 4, tags: { military: 'naval_base' } },
],
});
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
test('K-33: unnamed installations keep English names in data', () => {
assert.deepEqual(names, [
'Firing range',
'Military land',
'Military airfield',
'Naval base',
'Military area',
]);
});
test('K-33: unnamed installation card title reads in the UI language', () => {
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names) {
const shown = installationTitle(name);
assert.ok(shown, `${locale}: "${name}" has a title`);
assert.notEqual(
shown,
name,
`${locale}: "${name}" is shown in English`,
);
}
// A mapped name is shown as it is.
assert.equal(installationTitle('Fort Bragg'), 'Fort Bragg');
}
} finally {
setLocale('en');
}
});
test('K-33: English card titles are unchanged', () => {
setLocale('en');
for (const name of names) assert.equal(installationTitle(name), name);
assert.equal(installationTitle(''), '');
});
test('K-33: the Contacts line names an unnamed installation in the UI language', () => {
try {
setLocale('en');
for (const name of names)
assert.equal(formatAwarenessLabel({ id: 'osm-1', name }), name);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const name of names)
assert.equal(
formatAwarenessLabel({ id: 'osm-1', name }),
installationTitle(name),
);
assert.equal(
formatAwarenessLabel({ id: 'osm-2', name: 'Fort Bragg' }),
'Fort Bragg',
);
assert.equal(formatAwarenessLabel({ callsign: 'RCH123 ' }), 'RCH123');
}
} finally {
setLocale('en');
}
});
+100
View File
@@ -0,0 +1,100 @@
// K-37: the Local ADS-B click card follows the UI language; English keeps its
// wording (index.test.mjs pins the full English card).
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { localAdsbCardModel, localAdsbSourceText } from './card.js';
const record = {
icao: 'abc123',
callsign: '',
altitudeFt: 12500,
groundSpeedKt: 240,
trackDeg: 90,
verticalRateFpm: -1200,
lastPositionAt: 1000,
messageCount: 48,
bands: ['1090'],
sources: ['webusb', 'feed'],
};
test('en: the local ADS-B card keeps its English wording', () => {
setLocale('en');
assert.deepEqual(localAdsbCardModel(record, 5000).details, [
'ICAO ABC123 · NO CALLSIGN',
'ALT 12,500 FT · GS 240 KT · TRK 90°',
'V/S -1,200 FPM',
'POSITION 4 S AGO · 48 MSGS',
'Heard by your receiver · 1090 MHz · browser SDR + decoder feed',
]);
assert.equal(
localAdsbSourceText(record),
'Your RTL-SDR receiver and decoder feed',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the local ADS-B card is in the UI language`, () => {
setLocale(locale);
try {
const text = [
...localAdsbCardModel(record, 5000).details,
localAdsbSourceText(record),
].join(' | ');
for (const english of [
'NO CALLSIGN',
'GS ',
'POSITION',
'AGO',
'MSGS',
'Heard by',
'browser SDR',
'decoder feed',
'Your RTL-SDR',
'12,500',
])
assert.ok(
!text.includes(english),
`${locale}: "${english}" in ${text}`,
);
} finally {
setLocale('en');
}
});
}
test('the class line names the aircraft class in the UI language', async () => {
const { localAdsbClassLine } = await import('./card.js');
const { AIRCRAFT_CLASS_LABELS } = await import('../../data/aircraftClass.js');
const heli = { category: 'A7' };
try {
setLocale('en');
assert.equal(
localAdsbClassLine(heli, { klass: 'helicopter', evidence: true }),
'Helicopter · A7',
);
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const [klass, label] of Object.entries(AIRCRAFT_CLASS_LABELS)) {
const line = localAdsbClassLine({}, { klass, evidence: true });
assert.ok(line, `${locale}: ${klass} has a class line`);
assert.notEqual(line, label, `${locale}: "${label}" is in English`);
}
}
} finally {
setLocale('en');
}
});
// C8: the receiver bands follow the UI language too ("1090 МГц").
test('ru: the receiver line names the bands in Russian', async () => {
const { localAdsbReceiverLine } = await import('./card.js');
setLocale('ru');
try {
const line = localAdsbReceiverLine({ ...record, bands: ['1090', '978'] });
assert.match(line, /· 1090 МГц \+ 978 МГц UAT ·/);
assert.doesNotMatch(line, /MHz/);
} finally {
setLocale('en');
}
});
+55 -22
View File
@@ -1,10 +1,6 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import {
BAND_LABELS,
HEARD_BY_RECEIVER,
LOCAL_ADSB_COLOR,
SOURCE_LABELS,
} from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
const DASH = '—';
@@ -14,7 +10,7 @@ function rounded(value, digits = 0) {
}
function grouped(value) {
return value === null ? DASH : value.toLocaleString('en-US');
return value === null ? DASH : formatNumber(value);
}
/**
@@ -55,17 +51,41 @@ export function localAdsbIsUat(record) {
* @returns {string}
*/
export function localAdsbReceiverLine(record) {
const bands = recordBands(record)
.map((band) => BAND_LABELS[band])
.filter(Boolean);
const sources = recordSources(record)
.map((source) => SOURCE_LABELS[source])
.filter(Boolean);
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
const bands = recordBands(record).map(bandLabel).filter(Boolean);
const sources = recordSources(record).map(sourceLabel).filter(Boolean);
return [t('Heard by your receiver'), bands.join(' + '), sources.join(' + ')]
.filter(Boolean)
.join(' · ');
}
// policy.js keeps these labels in English (HEARD_BY_RECEIVER, BAND_LABELS,
// SOURCE_LABELS); the card writes them out here so that it can show them
// translated.
/** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */
function bandLabel(band) {
switch (String(band)) {
case '1090':
return t('1090 MHz');
case '978':
return t('978 MHz UAT');
default:
return BAND_LABELS[band];
}
}
/** A receiver source (policy.js SOURCE_LABELS) in the UI language. */
function sourceLabel(source) {
switch (source) {
case 'webusb':
return t('browser SDR');
case 'feed':
return t('decoder feed');
default:
return SOURCE_LABELS[source];
}
}
/**
* Where a heard aircraft came from, for the entity context's source field.
* @param {object} record Local ADS-B record.
@@ -74,9 +94,9 @@ export function localAdsbReceiverLine(record) {
export function localAdsbSourceText(record) {
const sources = recordSources(record);
if (sources.includes('webusb') && sources.includes('feed'))
return 'Your RTL-SDR receiver and decoder feed';
if (sources.includes('feed')) return 'Your decoder feed';
return 'Your RTL-SDR receiver';
return t('Your RTL-SDR receiver and decoder feed');
if (sources.includes('feed')) return t('Your decoder feed');
return t('Your RTL-SDR receiver');
}
/**
@@ -89,7 +109,7 @@ export function localAdsbSourceText(record) {
export function localAdsbClassLine(record, aircraftClass = {}) {
if (!aircraftClass.evidence) return '';
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
return [label, record?.category].filter(Boolean).join(' · ');
return [label && t(label), record?.category].filter(Boolean).join(' · ');
}
/**
@@ -129,13 +149,26 @@ export function localAdsbCardModel(
return {
title: localAdsbTitle(record),
details: [
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
`ICAO ${icao} · ${record?.callsign || t('NO CALLSIGN')}`,
localAdsbClassLine(record, aircraftClass || {}),
identity,
routeLine,
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
: t('V/S {rate} FPM', {
rate: `${vertical > 0 ? '+' : ''}${grouped(vertical)}`,
}),
[
ageS === null
? t('POSITION {age}', { age: DASH })
: t('POSITION {seconds} S AGO', { seconds: ageS }),
tn(messages, '{count} MSG', '{count} MSGS'),
].join(' · '),
localAdsbReceiverLine(record),
].filter(Boolean),
accent: LOCAL_ADSB_COLOR,
+9 -3
View File
@@ -1,6 +1,9 @@
import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.js';
// Marks English that the Layers panel translates when it shows the hint.
const msg = (text) => text;
/**
* Layers-panel status for Local ADS-B, from its two inputs: the browser
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
@@ -58,12 +61,15 @@ function webUsbStatus(receiver, heard, feedState) {
if (!receiver.webUsbSupported)
return {
status: 'idle',
statusMessage: 'WebUSB needs desktop Chrome or Edge',
statusMessage: msg('WebUSB needs desktop Chrome or Edge'),
};
if (!receiver.connected)
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
return {
status: 'idle',
statusMessage: msg('connect a receiver in Radio'),
};
if (receiver.mode !== 'adsb')
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: 'listening';
+8 -2
View File
@@ -1,5 +1,9 @@
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
// Marks English the Layers panel translates when it shows the error. Kept
// local: this module stays free of browser globals (package boundaries).
const msg = (text) => text;
/** Own military source acquisition, cancellation, freshness and error backoff. */
export function createIngestion({
feed,
@@ -42,7 +46,7 @@ export function createIngestion({
feed._lastError =
snapshot.reason ||
(snapshot.freshness === 'unknown'
? 'Source snapshot time unavailable'
? msg('Source snapshot time unavailable')
: null);
const accepted = applySnapshot(snapshot, viewer);
feed._count = accepted.count;
@@ -69,7 +73,9 @@ export function createIngestion({
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
+150
View File
@@ -0,0 +1,150 @@
// K-37 / K-29: the selected-fire card of Fire Perimeters and its accessible
// label follow the UI language, like the legend of the same layer
// (perimeters/index.js). cards.test.mjs pins the English card.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { buildIncidentCard } from './cards.js';
const now = Date.UTC(2026, 9, 14);
const row = {
stableId: 'x',
name: 'Lake',
acres: 1200,
containedPct: 40,
state: 'CA',
discoveredTime: now - 2 * 3600e3,
updatedTime: now - 3600e3,
cause: 'Human',
behavior: 'Moderate',
complexity: 'Type 3 Incident',
personnel: 1500,
county: 'Plumas',
costToDate: 4200000,
complexName: 'NORTH COMPLEX',
};
test('en: the fire card keeps its English wording', () => {
setLocale('en');
const card = buildIncidentCard(row, now, { link: 'https://inciweb.example' });
assert.equal(card.title, 'FIRE · Lake');
assert.deepEqual(card.details, [
'1,200 ac · 40% contained · CA',
'part of North Complex',
'Human cause · Moderate · Type 3 Incident',
'1,500 personnel · Plumas County · $4.2M to date',
'discovered 2h ago · updated 1h ago',
'InciWeb ↗ · click card to open',
]);
assert.equal(card.accessibilityLabel, 'Open FIRE · Lake on InciWeb');
});
for (const locale of ['es', 'ru']) {
test(`${locale}: selected fire card follows the UI language`, () => {
setLocale(locale);
try {
assert.notEqual(
t('Fully contained'),
'Fully contained',
'legend is translated',
);
const card = buildIncidentCard(row, now, {
link: 'https://inciweb.example',
});
const text = [card.title, ...card.details, card.accessibilityLabel].join(
' | ',
);
for (const english of [
'FIRE',
' ac ',
'contained',
'discovered',
'updated',
'part of',
'cause',
'personnel',
'County',
'to date',
'click card to open',
'Open ',
])
assert.ok(
!text.includes(english),
`${locale}: English "${english}" in ${text}`,
);
assert.ok(
!text.includes('1,200'),
`${locale}: en-US grouping in ${text}`,
);
} finally {
setLocale('en');
}
});
}
// C5 / C11 / E7: known WFIGS values, the cost and the unnamed title follow the
// UI language; the row keeps the feed's English for the AI tools.
const expected = {
es: {
situation: 'causa: humana · actividad moderada · incidente de tipo 3',
cost: '4,2 M$ hasta la fecha',
unnamed: 'INCENDIO · Sin nombre',
},
ru: {
situation:
'причина: деятельность человека · умеренная активность · инцидент 3-го типа',
cost: '4,2 млн $ на сегодня',
unnamed: 'ПОЖАР · Без названия',
},
};
for (const [locale, want] of Object.entries(expected)) {
test(`${locale}: feed values, cost and unnamed title are localized`, () => {
setLocale(locale);
try {
const card = buildIncidentCard(row, now);
assert.equal(card.details[2], want.situation);
assert.ok(
card.details[3].endsWith(want.cost),
`${locale}: ${card.details[3]}`,
);
assert.equal(
buildIncidentCard({ ...row, name: '' }, now).title,
want.unnamed,
);
assert.equal(row.cause, 'Human', 'the row keeps the feed value');
} finally {
setLocale('en');
}
});
}
test('unknown WFIGS values are shown as sent', () => {
setLocale('ru');
try {
const card = buildIncidentCard(
{
...row,
cause: 'Investigated',
behavior: 'Smoldering',
complexity: 'Complex Incident',
},
now,
);
assert.equal(
card.details[2],
'причина: Investigated · Smoldering · Complex Incident',
);
} finally {
setLocale('en');
}
});
test('en: compact costs keep the dollar prefix', () => {
setLocale('en');
const cost = (costToDate) =>
buildIncidentCard({ ...row, costToDate }, now).details[3].split(' · ')[2];
assert.equal(cost(85_000), '$85K to date');
assert.equal(cost(999_600), '$1M to date');
assert.equal(cost(1_300_000_000), '$1.3B to date');
});
+102 -30
View File
@@ -1,4 +1,5 @@
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
import { formatNumber, t, tn } from '../../i18n/index.js';
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
@@ -50,25 +51,70 @@ export function perimeterAnchorDegrees(polygons) {
return { lon: lon / count, lat: lat / count };
}
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
/**
* Compact USD in the UI language: en "$85K / $4.2M / $1.3B", ru "4,2 млн $",
* es "4,2 M$". Null under $1,000.
*/
function formatCost(dollars) {
if (!Number.isFinite(dollars) || dollars < 1000) return null;
const units = [
[1e3, 'K'],
[1e6, 'M'],
[1e9, 'B'],
];
const units = [1e3, 1e6, 1e9];
let index = 0;
for (let i = units.length - 1; i >= 0; i--) {
if (dollars >= units[i][0]) {
if (dollars >= units[i]) {
index = i;
break;
}
}
// 999,500 must promote to $1M, never render as $1000K.
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index][0];
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
if (dollars >= units[index] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index];
const amount = formatNumber(
value >= 10 ? Math.round(value) : Math.round(value * 10) / 10,
);
if (index === 0) return t('${amount}K', { amount });
if (index === 1) return t('${amount}M', { amount });
return t('${amount}B', { amount });
}
/** WFIGS attr_FireCause values in the UI language; others as sent. */
function causeLabel(cause) {
switch (cause) {
case 'Human':
return t('Human', { context: 'fire cause' });
case 'Natural':
return t('Natural', { context: 'fire cause' });
case 'Undetermined':
return t('Undetermined', { context: 'fire cause' });
case 'Unknown':
return t('Unknown', { context: 'fire cause' });
default:
return cause;
}
}
/** WFIGS attr_FireBehaviorGeneral values in the UI language; others as sent. */
function behaviorLabel(behavior) {
switch (behavior) {
case 'Minimal':
return t('Minimal', { context: 'fire behavior' });
case 'Moderate':
return t('Moderate', { context: 'fire behavior' });
case 'Active':
return t('Active', { context: 'fire behavior' });
case 'Extreme':
return t('Extreme', { context: 'fire behavior' });
default:
return behavior;
}
}
/**
* WFIGS attr_IncidentComplexityLevel ("Type 1 Incident" … "Type 5 Incident")
* in the UI language; other values as sent.
*/
function complexityLabel(complexity) {
const match = /^Type ([1-5]) Incident$/.exec(complexity);
return match ? t('Type {level} Incident', { level: match[1] }) : complexity;
}
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
@@ -78,12 +124,17 @@ function titleCase(name) {
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
/** Compact age, "5m" / "3h" / "2d", in the UI language ("3 h", "3 ч"). */
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = Math.floor(deltaMs / 3600000);
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
if (hours < 1)
return t('{count}m', {
context: 'age',
count: Math.max(1, Math.floor(deltaMs / 60000)),
});
if (hours < 24) return t('{count}h', { context: 'age', count: hours });
return t('{count}d', { context: 'age', count: Math.floor(hours / 24) });
}
/**
@@ -95,48 +146,67 @@ function formatAge(deltaMs) {
* @returns {Object} World-overlay entry without `position`.
*/
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// Feed values stay as WFIGS sends them in the row (and in what the AI tools
// read); the card translates the known cause, behavior and complexity values
// and the wording around them. State, category, county and complex names
// are shown as sent.
const facts = [];
if (Number.isFinite(row.acres))
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
if (Number.isFinite(row.acres)) {
const acres = Math.round(row.acres);
facts.push(
tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }),
);
}
facts.push(
Number.isFinite(row.containedPct)
? `${Math.round(row.containedPct)}% contained`
: 'containment unknown',
? t('{percent}% contained', { percent: Math.round(row.containedPct) })
: t('containment unknown'),
);
if (row.state) facts.push(row.state);
else if (row.category) facts.push(row.category);
const situation = [];
if (row.cause) situation.push(`${row.cause} cause`);
if (row.behavior) situation.push(row.behavior);
if (row.complexity) situation.push(row.complexity);
if (row.cause)
situation.push(t('{cause} cause', { cause: causeLabel(row.cause) }));
if (row.behavior) situation.push(behaviorLabel(row.behavior));
if (row.complexity) situation.push(complexityLabel(row.complexity));
const response = [];
if (Number.isFinite(row.personnel))
if (Number.isFinite(row.personnel)) {
const personnel = Math.round(row.personnel);
response.push(
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
tn(personnel, '{personnel} personnel', '{personnel} personnel', {
personnel: formatNumber(personnel),
}),
);
if (row.county) response.push(`${row.county} County`);
}
if (row.county) response.push(t('{county} County', { county: row.county }));
if (Number.isFinite(row.costToDate)) {
const cost = formatCost(row.costToDate);
if (cost) response.push(`${cost} to date`);
if (cost) response.push(t('{cost} to date', { cost }));
}
const ages = [];
const discovered = formatAge(nowMs - row.discoveredTime);
if (row.discoveredTime != null && discovered)
ages.push(`discovered ${discovered} ago`);
ages.push(t('discovered {age} ago', { age: discovered }));
const updated = formatAge(nowMs - row.updatedTime);
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
if (row.updatedTime != null && updated)
ages.push(t('updated {age} ago', { age: updated }));
const details = [facts.join(' · ')];
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
if (row.complexName)
details.push(
t('part of {complex}', { complex: titleCase(row.complexName) }),
);
if (situation.length) details.push(situation.join(' · '));
if (response.length) details.push(response.join(' · '));
if (ages.length) details.push(ages.join(' · '));
if (link) details.push('InciWeb ↗ · click card to open');
if (link) details.push(t('InciWeb ↗ · click card to open'));
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
const title = t('FIRE · {name}', {
name: row.name || t('Unnamed incident'),
});
return {
id: `fire-perimeter-card:${row.stableId}`,
// `selected` picks the paint lane and collision protection; an explicit
@@ -145,7 +215,9 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) {
// Only a linked card is clickable — the overlay host registers hit
// rectangles solely for interactive entries.
interactive: Boolean(link),
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
...(link
? { accessibilityLabel: t('Open {title} on InciWeb', { title }) }
: undefined),
title,
details,
accent: containmentAccent(row.containedPct),
+2 -2
View File
@@ -10,7 +10,7 @@ import {
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js';
@@ -365,7 +365,7 @@ export function createFirePerimetersLayer({
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || 'Perimeter source unavailable';
_lastError = e?.message || msg('Perimeter source unavailable');
return false;
} finally {
if (_request === request) _request = null;
+18 -4
View File
@@ -1,8 +1,22 @@
import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
// the Radio panel translate them when shown.
const ERROR_REFRESH_DEGRADED = msg(
'Directory refresh degraded; showing the previous station catalog.',
);
const ERROR_COVERAGE_DEGRADED = msg('Radio directory coverage is degraded.');
const ERROR_REFRESH_FAILED = msg(
'Directory refresh failed; showing the previous station catalog.',
);
const ERROR_DIRECTORY_UNAVAILABLE = msg(
'Radio directory is temporarily unavailable.',
);
export function createIngestion({
state: layerState,
services,
@@ -136,9 +150,9 @@ export function createIngestion({
layerState._degraded = body.degraded;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? 'Directory refresh degraded; showing the previous station catalog.'
? ERROR_REFRESH_DEGRADED
: layerState._degraded
? 'Radio directory coverage is degraded.'
? ERROR_COVERAGE_DEGRADED
: null;
} catch (error) {
if (
@@ -153,8 +167,8 @@ export function createIngestion({
)
return;
layerState._error = layerState._stations.length
? 'Directory refresh failed; showing the previous station catalog.'
: 'Radio directory is temporarily unavailable.';
? ERROR_REFRESH_FAILED
: ERROR_DIRECTORY_UNAVAILABLE;
layerState._stale = layerState._stations.length > 0;
layerState._degraded = layerState._stations.length > 0;
} finally {
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t, translateKnown } from '../../i18n/index.js';
import { createIngestion } from './ingestion.js';
/**
* Directory errors are stored in English in layer state (layerPanel and the
* Radio panel translate them when shown). Every error the ingestion can set
* must therefore have a catalog entry in each shipped language.
*/
function makeIngestion({ stations = [], body = null, failure = null } = {}) {
const layerState = {
_enabled: true,
_requestGeneration: 0,
_sessionGeneration: 0,
_abortController: null,
_loading: false,
_error: null,
_stale: false,
_degraded: false,
_stations: stations,
_updatedAt: null,
_tuningActive: false,
_cancelledTuningPresentationStation: null,
_acceptedCatalogSnapshot: { generation: null, instance: null },
};
const parts = {
presentation: { emitState() {} },
model: { radioRequestIsCurrent: () => true },
catalogModel: {
isValidRadioDirectoryStation: () => true,
freezeRadioStation: (station) => station,
createAcceptedCatalogSnapshot: () => null,
},
rendering: { reconcileStations() {}, updateSelectionEntity() {} },
};
const source = {
async getDirectory() {
if (failure) throw failure;
return body;
},
};
const { methods } = createIngestion({
state: layerState,
services: {},
parts,
source,
});
return { layerState, methods };
}
const degradedBody = () => ({
stations: [{ id: 'a' }],
updatedAt: new Date().toISOString(),
stale: false,
degraded: true,
acceptedGeneration: 1,
catalogInstance: 'instance-1',
});
const CASES = [
{
name: 'directory unavailable without a catalog',
options: { failure: new Error('network') },
expected: 'Radio directory is temporarily unavailable.',
},
{
name: 'refresh failed with a warm catalog',
options: { stations: [{ id: 'a' }], failure: new Error('network') },
expected: 'Directory refresh failed; showing the previous station catalog.',
},
{
name: 'degraded coverage without a catalog',
options: { body: degradedBody() },
expected: 'Radio directory coverage is degraded.',
},
{
name: 'degraded refresh with a warm catalog',
options: { stations: [{ id: 'a' }], body: degradedBody() },
expected:
'Directory refresh degraded; showing the previous station catalog.',
},
];
for (const { name, options, expected } of CASES) {
test(`radio directory error is English in state and translated on show: ${name}`, async () => {
const { layerState, methods } = makeIngestion(options);
await methods.update();
assert.equal(layerState._error, expected);
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
const shown = translateKnown(layerState._error);
assert.notEqual(shown, expected, `${locale}: ${name}`);
assert.equal(t(layerState._error), shown);
}
} finally {
setLocale('en');
}
});
}
+16 -4
View File
@@ -1,5 +1,18 @@
import { msg } from '../../i18n/index.js';
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
// Playback errors stay English in layer state; the Radio panel shows them
// through t().
const AUDIO_ERROR_STREAM_FAILED = msg(
'Broadcaster stream is unavailable or blocked by the browser.',
);
const AUDIO_ERROR_GESTURE_REQUIRED = msg(
'Playback requires a direct click or tap.',
);
const AUDIO_ERROR_START_FAILED = msg(
'Broadcaster stream could not be started.',
);
export function createPlayback({ state: layerState, services, parts, source }) {
function audioEventBelongsToActiveAttempt(audio) {
const attempt = layerState._activePlaybackAttempt;
@@ -75,8 +88,7 @@ export function createPlayback({ state: layerState, services, parts, source }) {
const attempt = layerState._activePlaybackAttempt;
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
layerState._audioState = 'error';
layerState._audioError =
'Broadcaster stream is unavailable or blocked by the browser.';
layerState._audioError = AUDIO_ERROR_STREAM_FAILED;
parts.presentation.emitState();
});
}
@@ -202,8 +214,8 @@ export function createPlayback({ state: layerState, services, parts, source }) {
layerState._audioState = 'error';
layerState._audioError =
error?.name === 'NotAllowedError'
? 'Playback requires a direct click or tap.'
: 'Broadcaster stream could not be started.';
? AUDIO_ERROR_GESTURE_REQUIRED
: AUDIO_ERROR_START_FAILED;
parts.presentation.emitState();
return false;
}
+1 -1
View File
@@ -632,7 +632,7 @@ export function createRecentImageryLayer({
} catch (error) {
if (abort.signal.aborted) return;
resetCatalog();
_catalogError = error?.message || 'Imagery catalog unavailable';
_catalogError = error?.message || msg('Imagery catalog unavailable');
} finally {
if (_searchAbort === abort) {
_searchAbort = null;
+5 -4
View File
@@ -132,11 +132,12 @@ const refuse = (reason, message) => ({ ok: false, reason, message });
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
if (!normalized) return refuse('invalid', msg('Box edges must be finite'));
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
return refuse('invalid', msg('Longitudes must be within ±180°'));
if (west > east)
return refuse('dateline', msg('Select one side of the dateline'));
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
@@ -146,7 +147,7 @@ export function validateBox(box) {
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
return refuse('degenerate', msg('Box must enclose an area'));
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
+3 -1
View File
@@ -1,5 +1,6 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -59,7 +60,8 @@ export function createIngestion({
// the chip still read "just now". Keep the existing (stale) catalog on
// screen and surface the outage instead — do NOT stamp _lastUpdate.
if (results.every((r) => !r.ok)) {
layerState._lastError = 'CelesTrak unreachable';
// English data (stats reach the voice tools); the panel translates it.
layerState._lastError = msg('CelesTrak unreachable');
console.warn(
'[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage',
);
+11 -2
View File
@@ -1,5 +1,7 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { t } from '../../i18n/index.js';
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
import {
ISS_NORAD,
CONTEXT_REFRESH_INTERVAL_MS,
@@ -354,7 +356,12 @@ export function createTracking({ state: layerState, services, parts, source }) {
const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`;
const altitudeM =
layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM;
const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`;
// The card is display only (the voice context slot keeps its own English
// copy), so the altitude unit and the companions line follow the language.
const altitudeText = Number.isFinite(altitudeM)
? formatQuantity(Math.round(altitudeM / 1000), 'km')
: `?\u00a0${unitLabel('km')}`;
const detail = `${altitudeText} · NORAD ${layerState._trackedNorad}`;
// Class leads the detail block: it is what tells the operator WHAT they are
// looking at, and it stays readable under the IR styles that flatten the
// dot colors to a single channel (the card is painted above post-FX).
@@ -372,7 +379,9 @@ export function createTracking({ state: layerState, services, parts, source }) {
if (companions.length > 0) {
const extra = companions.length - 1;
details.push(
`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`,
extra > 0
? t('DOCKED · {name} · +{extra}', { name: companions[0], extra })
: t('DOCKED · {name}', { name: companions[0] }),
);
}
const current = layerState._trackedEntity.gevLabelModel;
+18 -4
View File
@@ -18,7 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { t, translateKnown } from '../../i18n/index.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
@@ -583,10 +583,13 @@ export function createStreetLevelLayer({
// The lifecycle polls this every second: summarise, don't snapshot.
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
// English data: the layer panel translates these through translateKnown.
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
const keyLabel = coverage.keyRejected
? msg('KEY REJECTED')
: msg('KEY REQUIRED');
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.loading) loadingLabel = msg('loading coverage...');
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
source: layer.source,
@@ -737,10 +740,21 @@ export function createStreetLevelLayer({
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
// A provider's own refusal is English data that the panel translates;
// the core's refusal is built here, for the panel only, in the language.
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
(point
? t('No street-level imagery within {radius} m of that point', {
radius: NEAREST_RADIUS_M,
})
: t(
'No street-level imagery within {radius} m of the view centre',
{
radius: NEAREST_RADIUS_M,
},
));
state.street.loading = false;
notify();
return false;
+19
View File
@@ -6,6 +6,7 @@ import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
@@ -690,6 +691,24 @@ test('a lookup for the click-to-open providers asks only them and reports "that
);
});
test('the nearest-image refusal is written in the language of the page', async (t) => {
const { layer } = await withStreetView(t, { nearest: () => null });
layer.setProviderEnabled('google', true);
await settle();
setLocale('es');
try {
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
const error = layer.getUIState().street.error;
assert.notEqual(error, 'No street-level imagery within 50 m of that point');
assert.match(error, /50/, 'keeps the radius');
} finally {
setLocale('en');
}
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
+8 -2
View File
@@ -1,3 +1,7 @@
// Reached by the providers' source modules (no browser globals, see
// check:boundaries), so no i18n import: the text stays English data here.
const msg = (text) => text;
/** Identity and shared tuning for the provider-neutral Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
@@ -41,5 +45,7 @@ export const FOLLOW_EYE_HEIGHT_M = 2.4;
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE =
'Aim the middle of the view at a street to look for imagery there';
/** English data: the Street Level panel translates it (translateKnown). */
export const AIM_AT_GROUND_MESSAGE = msg(
'Aim the middle of the view at a street to look for imagery there',
);
@@ -3,6 +3,8 @@
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
*/
import { msg } from '../../../../i18n/index.js';
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
const READY_CALLBACK = '__gevGoogleMapsReady';
/** The page's one script load, shared by every loader on it. */
@@ -44,7 +46,7 @@ export function createMapsLoader({
function load() {
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
return Promise.reject(new Error(msg('GOOGLE_MAPS_API_KEY is not set')));
installAuthHandler();
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
if (global.google?.maps?.importLibrary)
@@ -69,7 +71,7 @@ export function createMapsLoader({
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
reject(new Error(msg('The Google Maps JavaScript API could not load')));
};
document.head.append(script);
});
@@ -9,6 +9,7 @@ import {
imageDateMs,
streetViewUrl,
} from './policy.js';
import { msg } from '../../../../i18n/index.js';
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
@@ -204,7 +205,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
new Error(msg('Street View has no imagery for this place')),
);
};
const onPosition = () => {
@@ -9,6 +9,7 @@ import {
} from './decode.js';
import { createKartaViewSource } from './source.js';
import { createKartaViewViewer } from './viewer.js';
import { msg } from '../../../../i18n/index.js';
import {
ARCHIVED_MESSAGE,
COLORS,
@@ -195,7 +196,8 @@ export function createKartaViewProvider({
const cached = photos.get(photoId);
if (cached) return cached;
const photo = remember(await source.getPhoto(photoId, { signal }));
if (!photo) throw new Error('KartaView has no position for this photo');
if (!photo)
throw new Error(msg('KartaView has no position for this photo'));
return photo;
}
@@ -491,7 +493,8 @@ export function createKartaViewProvider({
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The KartaView photo could not be loaded',
error?.message ||
msg('The KartaView photo could not be loaded'),
),
),
render: context.services?.render,
@@ -50,8 +50,9 @@ export const COVERAGE_TUNING = Object.freeze({
});
export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage');
export const NO_COVERAGE_HINT = msg('no KartaView photos in view');
export const RATE_LIMITED_MESSAGE =
'KartaView is rate-limiting requests — they resume on their own';
export const RATE_LIMITED_MESSAGE = msg(
'KartaView is rate-limiting requests — they resume on their own',
);
/**
* The photo API answers 429 to more than about three requests at once, so
@@ -82,8 +83,9 @@ export const IMAGE_TIMEOUT_MS = 15_000;
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
export const NEAREST_PROBES = 6;
export const ARCHIVED_MESSAGE =
'KartaView’s photos here are archived and cannot be shown';
export const ARCHIVED_MESSAGE = msg(
'KartaView’s photos here are archived and cannot be shown',
);
/** Photos asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
@@ -136,10 +138,12 @@ export const MASK_MAX_BYTES = 2 * 1024 * 1024;
export const MASK_KEEP_SHARE = 0.5;
/** Points along a segment are tested this many mask pixels apart. */
export const MASK_SAMPLE_PX = 2;
export const MASK_SLOW_MESSAGE =
'KartaView did not filter this area in time — move the map to ask again';
export const FILTERED_OUT_MESSAGE =
'None of KartaView’s photos here pass the imagery filter';
export const MASK_SLOW_MESSAGE = msg(
'KartaView did not filter this area in time — move the map to ask again',
);
export const FILTERED_OUT_MESSAGE = msg(
'None of KartaView’s photos here pass the imagery filter',
);
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = 'KartaView: by upload date';
@@ -21,6 +21,7 @@ import {
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,
@@ -40,9 +41,10 @@ export class KartaViewSourceError extends Error {
}
}
const NO_ANSWER = 'KartaView did not answer in time — try again';
const UNREACHABLE = 'KartaView could not be reached — check the network';
const UNREADABLE = 'KartaView sent an answer that could not be read';
// 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]);
@@ -169,10 +171,15 @@ export function createKartaViewSource({
try {
response = await fetchImpl(url, { signal: controller.signal });
if (response.status === 429)
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
status: 429,
retryAfterSec: retryAfterSeconds(response) ?? 2,
});
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}`,
@@ -180,7 +187,7 @@ export function createKartaViewSource({
);
const length = Number(response.headers?.get?.('content-length'));
const tooMuch = () =>
new KartaViewSourceError('KartaView sent too much data', {
new KartaViewSourceError(msg('KartaView sent too much data'), {
status: response.status,
});
if (length > maxBytes) throw tooMuch();
@@ -275,6 +282,7 @@ export function createKartaViewSource({
);
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})`,
@@ -288,7 +296,9 @@ export function createKartaViewSource({
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',
msg(
'KartaView is rate-limiting requests — try again in a moment',
),
{ status: 429, retryAfterSec: error.retryAfterSec },
);
throw error;
@@ -419,7 +429,7 @@ export function createKartaViewSource({
{ signal },
);
if (!result.data || typeof result.data !== 'object')
throw new KartaViewSourceError('KartaView has no such photo');
throw new KartaViewSourceError(msg('KartaView has no such photo'));
return result.data;
},
@@ -3,7 +3,7 @@ import {
PANO_FOV_DEFAULT_DEG,
wrapDegrees,
} from './panorama.js';
import { t } from '../../../../i18n/index.js';
import { msg, t } from '../../../../i18n/index.js';
import {
FLAT_FULL_SIZE,
FLAT_VIEW_SIZE,
@@ -17,7 +17,8 @@ import {
resizedImageUrl,
} from './policy.js';
const LOAD_FAILED = 'The KartaView photo could not be loaded';
/** English data: the Street Level panel translates it (translateKnown). */
const LOAD_FAILED = msg('The KartaView photo could not be loaded');
/**
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
@@ -1,5 +1,9 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
// Reached by source.js (no browser globals, see check:boundaries), so no
// i18n import: the messages stay English data, translated where shown.
const msg = (text) => text;
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
@@ -36,11 +40,13 @@ export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
export const KEY_REJECTED_MESSAGE = msg(
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings',
);
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own';
export const RATE_LIMITED_MESSAGE = msg(
'Coverage requests are being rate-limited — they resume on their own',
);
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
+4 -2
View File
@@ -1,3 +1,4 @@
import { msg } from '../../i18n/index.js';
import { freshStreet } from './state.js';
/**
@@ -124,7 +125,8 @@ export function createViewerHost({ state, parts }) {
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
state.street.error = 'Open the Street Level panel to view imagery';
// English data: the Street Level panel translates it (translateKnown).
state.street.error = msg('Open the Street Level panel to view imagery');
notify();
return false;
}
@@ -152,7 +154,7 @@ export function createViewerHost({ state, parts }) {
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
} catch (error) {
if (current())
state.street.error = error?.message || 'Image could not be opened';
state.street.error = error?.message || msg('Image could not be opened');
} finally {
if (current()) state.street.loading = false;
notify();
+2 -2
View File
@@ -1,4 +1,4 @@
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import {
featureLabel,
featureReference,
@@ -190,7 +190,7 @@ export function createIngestion({ state, parts, source }) {
// A stale or aborted load reports nothing: its failure belongs to a
// lifecycle the user already left.
if (owns() && error?.name !== 'AbortError') {
state._error = error?.message || 'TeleGeography load failed';
state._error = error?.message || msg('TeleGeography load failed');
console.warn(
'[Data:telegeography-submarine-cables]',
state._error,
+34 -15
View File
@@ -8,15 +8,17 @@ import {
trafficStyleProfile,
} from '../../data/trafficPresetStyle.js';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { t, translateKnown } from '../../i18n/index.js';
export function createControls({ state: layerState, services, parts, source }) {
/** One status line: feed, drawn road source, then degraded-coverage notes. */
function roadStatusLabel(feed) {
const source = layerState._roadSource;
if (layerState._roadError)
return `UNAVAILABLE · ${source} · Roads unavailable`;
return t('UNAVAILABLE · {source} · Roads unavailable', { source });
const notes = [feed.loadingLabel];
if (!layerState._liveMode || feed.error) notes.push(`Roads: ${source}`);
if (!layerState._liveMode || feed.error)
notes.push(t('Roads: {source}', { source }));
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
if (
!layerState._liveMode &&
@@ -24,16 +26,24 @@ export function createControls({ state: layerState, services, parts, source }) {
)
notes.push(
layerState._flowStatusUnavailable
? `${layerState._roadMode === 'tomtom' ? 'TomTom roads' : 'Hybrid'} unavailable while the traffic service is unreachable`
? layerState._roadMode === 'tomtom'
? t(
'TomTom roads unavailable while the traffic service is unreachable',
)
: t('Hybrid unavailable while the traffic service is unreachable')
: layerState._roadMode === 'tomtom'
? 'TomTom roads need a TomTom key'
: 'Hybrid needs a TomTom key',
? t('TomTom roads need a TomTom key')
: t('Hybrid needs a TomTom key'),
);
if (layerState._surfacePending) notes.push('Local surface still loading');
if (layerState._roadWarning) notes.push(layerState._roadWarning);
else if (layerState._roadPartial) notes.push('Partial coverage');
if (layerState._detailError) notes.push('Detailed roads unavailable');
else if (layerState._detailLimited) notes.push('Reduced detail coverage');
if (layerState._surfacePending)
notes.push(t('Local surface still loading'));
// The warning is a road-source message (source.js marks the known ones).
if (layerState._roadWarning)
notes.push(translateKnown(layerState._roadWarning));
else if (layerState._roadPartial) notes.push(t('Partial coverage'));
if (layerState._detailError) notes.push(t('Detailed roads unavailable'));
else if (layerState._detailLimited)
notes.push(t('Reduced detail coverage'));
return notes.join(' · ');
}
@@ -161,13 +171,22 @@ export function createControls({ state: layerState, services, parts, source }) {
getRowControls() {
const selected =
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
const needsKey = layerState._liveMode ? '' : ' (needs a TomTom key)';
const live = layerState._liveMode;
const titles = {
tomtom: `TomTom roads only, each with its live flow${needsKey}`,
osm: 'OpenStreetMap roads, with TomTom flow matched when available',
hybrid: `TomTom roads first; OpenStreetMap fills the rest, simulated${needsKey}`,
tomtom: live
? t('TomTom roads only, each with its live flow')
: t(
'TomTom roads only, each with its live flow (needs a TomTom key)',
),
osm: t('OpenStreetMap roads, with TomTom flow matched when available'),
hybrid: live
? t('TomTom roads first; OpenStreetMap fills the rest, simulated')
: t(
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
),
};
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: 'Hybrid' };
// TomTom and OSM are provider names and stay as they are.
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
return {
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
id: `roads-${mode}`,
+7 -4
View File
@@ -11,6 +11,7 @@ import {
FAST_FETCH_ALTITUDE,
roadDotBudget,
} from './policy.js';
import { msg } from '../../i18n/index.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
@@ -36,11 +37,13 @@ export function createFlow({ state: layerState, services, parts, source }) {
const status = Number.isFinite(error.status)
? error.status
: Number(message.match(/HTTP (\d{3})/)?.[1]);
if (status === 503) return 'TomTom key unavailable';
if (status === 429) return 'TomTom daily budget reached';
if (status === 502 || status === 504) return 'TomTom upstream unreachable';
// English in state; trafficFeedPresentation translates it for the panel.
if (status === 503) return msg('TomTom key unavailable');
if (status === 429) return msg('TomTom daily budget reached');
if (status === 502 || status === 504)
return msg('TomTom upstream unreachable');
if (Number.isFinite(status)) return `TomTom flow error (HTTP ${status})`;
return 'TomTom flow unavailable';
return msg('TomTom flow unavailable');
}
/**
+5 -2
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { roadSurfaceChunks } from './surface.js';
import { t } from '../../i18n/index.js';
import { t, translateKnown } from '../../i18n/index.js';
import {
flowDensityMult,
flowBucket,
@@ -252,7 +252,10 @@ export function createModel({ state: layerState, services, parts, source }) {
// drops `loadingLabel` in its error branch, so the owner's SIMULATED copy
// has to BE the error text or the steady state reverts to a bare
// "TomTom daily budget reached" that never says what is on screen.
const degraded = `SIMULATED — ${flowError}`;
// The reason is ours (flow.js marks it) or carries an HTTP code.
const degraded = t('SIMULATED — {reason}', {
reason: translateKnown(flowError),
});
return { mode, error: degraded, loadingLabel: degraded };
}
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
+126
View File
@@ -0,0 +1,126 @@
// K-37: the traffic road-status notes follow the UI language; English keeps
// its wording.
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { createState } from './state.js';
import { createControls } from './controls.js';
function loadingLabel(overrides) {
const state = createState({ services: {} });
Object.assign(state, overrides);
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: { model: { trafficFeedPresentation: () => ({ loadingLabel: '' }) } },
});
return controls.methods.getStats().loadingLabel;
}
const DEGRADED = {
_liveMode: false,
_roadMode: 'tomtom',
_roadSource: 'OpenStreetMap',
_roadPartial: true,
_detailLimited: true,
_surfacePending: 2,
};
test('en: road status notes keep their English wording', () => {
setLocale('en');
assert.equal(
loadingLabel(DEGRADED),
' · Roads: OpenStreetMap · TomTom roads need a TomTom key · Local surface still loading · Partial coverage · Reduced detail coverage',
);
assert.equal(
loadingLabel({ _roadError: 'x', _roadSource: 'OpenStreetMap' }),
'UNAVAILABLE · OpenStreetMap · Roads unavailable',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: road status notes are in the UI language`, () => {
setLocale(locale);
try {
const label = loadingLabel({
...DEGRADED,
_roadPartial: false,
_roadWarning: 'OpenFreeMap tiles rate-limited',
});
for (const english of [
'Roads:',
'TomTom key',
'still loading',
'rate-limited',
'detail coverage',
])
assert.ok(
!label.includes(english),
`${locale}: "${english}" in ${label}`,
);
} finally {
setLocale('en');
}
});
}
function roadChips(liveMode) {
const state = createState({ services: {} });
state._liveMode = liveMode;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods.getRowControls().chips;
}
test('en: road-source chips keep their English labels and titles', () => {
setLocale('en');
assert.deepEqual(
roadChips(false).map((chip) => [chip.label, chip.title]),
[
[
'TomTom',
'TomTom roads only, each with its live flow (needs a TomTom key)',
],
['OSM', 'OpenStreetMap roads, with TomTom flow matched when available'],
[
'Hybrid',
'TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)',
],
],
);
assert.equal(
roadChips(true)[2].title,
'TomTom roads first; OpenStreetMap fills the rest, simulated',
);
});
for (const [locale, hybrid] of [
['ru', 'Гибрид'],
['es', 'Híbrido'],
]) {
test(`${locale}: the Hybrid chip and every chip title are translated`, () => {
setLocale(locale);
try {
for (const live of [false, true]) {
const chips = roadChips(live);
assert.deepEqual(
chips.map((chip) => chip.label),
['TomTom', 'OSM', hybrid],
);
for (const chip of chips)
assert.doesNotMatch(
chip.title,
/roads only|roads, with|fills the rest|needs a/,
`${locale}: ${chip.title}`,
);
}
} finally {
setLocale('en');
}
});
}
+7 -3
View File
@@ -59,16 +59,20 @@ export class RoadRequestError extends Error {
}
}
// AI tool services import this module, so it marks its English with a local
// msg(); the traffic panel translates the message (controls.js).
const msg = (text) => text;
/** Name the road upstream without exposing raw transport errors or missing codes. */
export function roadRequestError(status, cause) {
const code = Number.isFinite(status) ? status : null;
const message =
code === 429
? 'OpenFreeMap tiles rate-limited'
? msg('OpenFreeMap tiles rate-limited')
: code === 504 || cause?.name === 'TimeoutError'
? 'OpenFreeMap tiles timed out'
? msg('OpenFreeMap tiles timed out')
: code === null
? 'OpenFreeMap tiles unavailable'
? msg('OpenFreeMap tiles unavailable')
: `OpenFreeMap tiles unavailable (HTTP ${code})`;
return Object.assign(new RoadRequestError(message, { status: code, cause }), {
retryable: cause?.retryable !== false,
+83 -28
View File
@@ -318,6 +318,44 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
};
}
// AI tools (src/tools/queries/mobility.js) import this module, so it stays
// free of i18n: the card copy below calls the translate function `t` that the
// caller hands in. The browser selection passes the real one from src/i18n;
// without it the copy is English.
const msg = (text) => text;
/** English {placeholder} interpolation, the default `t` of the card copy. */
function englishText(text, values) {
if (!values) return text;
return text.replace(/\{([A-Za-z0-9_]+)\}/g, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
}
/** GTFS-realtime OccupancyStatus in words. */
function occupancyText(occupancy, t) {
switch (occupancy) {
case 'EMPTY':
return t('empty', { context: 'occupancy' });
case 'MANY_SEATS_AVAILABLE':
return t('many seats available', { context: 'occupancy' });
case 'FEW_SEATS_AVAILABLE':
return t('few seats available', { context: 'occupancy' });
case 'STANDING_ROOM_ONLY':
return t('standing room only', { context: 'occupancy' });
case 'CRUSHED_STANDING_ROOM_ONLY':
return t('crushed standing room only', { context: 'occupancy' });
case 'FULL':
return t('full', { context: 'occupancy' });
case 'NOT_ACCEPTING_PASSENGERS':
return t('not accepting passengers', { context: 'occupancy' });
case 'NOT_BOARDABLE':
return t('not boardable', { context: 'occupancy' });
default:
return occupancy.toLowerCase().replaceAll('_', ' ');
}
}
/**
* Build the human lines of the selection card from a vehicle record.
* @param {object} feed Registry entry.
@@ -325,6 +363,9 @@ export function aggregateTransitFeedHealth(statuses, nowMs) {
* @param {string} mode Resolved transit mode.
* @param {number} nowMs
* @param {number|null} [fetchedAtMs] Snapshot fetch time.
* @param {object|null} [entry] Playback entry of the vehicle.
* @param {(text: string, values?: object) => string} [t] Translates the
* card's English text; English when omitted.
* @returns {{title:string, details:string[]}}
*/
export function buildTransitSelectionCopy(
@@ -334,44 +375,52 @@ export function buildTransitSelectionCopy(
nowMs,
fetchedAtMs = null,
entry = null,
t = englishText,
) {
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
const routeLabel = record.routeId
? `Route ${record.routeId}`
: record.label
? `Vehicle ${record.label}`
: `Vehicle ${record.id}`;
? t('Route {route}', { route: record.routeId })
: t('Vehicle {vehicle}', { vehicle: record.label || record.id });
const title = `${icon} ${routeLabel}`;
// The mode is said in words on its own line. A glyph and a route id do not
// tell a reader that the dot on Tremont Street is a subway train, and a
// subway drawn on a street reads as a bug until the card says what it is.
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
const kindWord = t(TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown);
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
const motion = [];
// What the SCREEN is doing leads, because that is what the reader can check.
const shown = entry ? displayMotion(entry, nowMs) : null;
if (shown?.word === 'STOPPED') motion.push('Stopped');
else if (shown?.word === 'NO FIX') motion.push('No recent fix');
else if (shown?.word === 'WAITING') motion.push('Waiting for update');
else if (shown) motion.push('Moving');
if (shown?.word === 'STOPPED')
motion.push(t('Stopped', { context: 'vehicle motion' }));
else if (shown?.word === 'NO FIX') motion.push(t('No recent fix'));
else if (shown?.word === 'WAITING') motion.push(t('Waiting for update'));
else if (shown) motion.push(t('Moving'));
if (motion.length) details.push(motion.join(' · '));
const reportedMotion = [];
if (Number.isFinite(record.speedMps))
reportedMotion.push(`reported ${Math.round(record.speedMps * 3.6)} km/h`);
reportedMotion.push(
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
);
if (Number.isFinite(record.bearing))
reportedMotion.push(`reported hdg ${Math.round(record.bearing)}°`);
reportedMotion.push(
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
// It describes the moment of the report, which the display may not have
// reached yet; presenting it as "now" is what put STOPPED on a moving metro.
const state = [];
if (record.status === 'STOPPED_AT' && record.stopId)
state.push(`Last report: stopped at stop ${record.stopId}`);
state.push(
t('Last report: stopped at stop {stop}', { stop: record.stopId }),
);
else if (record.status === 'INCOMING_AT' && record.stopId)
state.push(`Last report: arriving at stop ${record.stopId}`);
state.push(
t('Last report: arriving at stop {stop}', { stop: record.stopId }),
);
else if (record.status === 'IN_TRANSIT_TO' && record.stopId)
state.push(`Last report: next stop ${record.stopId}`);
state.push(t('Last report: next stop {stop}', { stop: record.stopId }));
if (record.occupancy && record.occupancy !== 'NO_DATA_AVAILABLE') {
state.push(record.occupancy.toLowerCase().replaceAll('_', ' '));
state.push(occupancyText(record.occupancy, t));
}
if (reportedMotion.length) details.push(reportedMotion.join(' · '));
if (state.length) details.push(state.join(' · '));
@@ -379,10 +428,10 @@ export function buildTransitSelectionCopy(
// That is a deliberate choice — a train nobody can see is not worth drawing —
// but it has to be stated, or a metro sitting on a road looks like an error.
if (UNDERGROUND_MODES.has(mode)) {
details.push('Shown at street level · depth not in the feed');
details.push(t('Shown at street level · depth not in the feed'));
}
details.push(
`Vehicle ${record.id}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
`${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`,
);
const ageS = Math.round(
(Number.isFinite(entry?.sample?.latestReportAgeMs)
@@ -391,8 +440,8 @@ export function buildTransitSelectionCopy(
);
const reported =
ageS < 90
? `Reported ${ageS} s ago`
: `Reported ${Math.round(ageS / 60)} min ago`;
? t('Reported {seconds} s ago', { seconds: ageS })
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
// A time the feed did not give for THIS vehicle is labelled, so the card
// never presents a borrowed or inferred time as the vehicle's own report.
const ownTime =
@@ -404,19 +453,25 @@ export function buildTransitSelectionCopy(
const lagS = Number.isFinite(entry?.sample?.actualDelayMs)
? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000))
: null;
const behind = lagS === null ? '' : ` · shown ${lagS} s behind`;
const behind =
lagS === null
? ''
: ` · ${t('shown {seconds} s behind', { seconds: lagS })}`;
details.splice(
motion.length ? 2 : 1,
0,
(ownTime ? reported : `${reported} (feed time)`) + behind,
(ownTime ? reported : t('{reported} (feed time)', { reported })) + behind,
);
if (entry?.track?.count) {
const duration = Math.max(
0,
Math.min(entry.playT, entry.fixes.at(-1).t) - entry.fixes[0].t,
);
const minutes = Math.round(duration / 60000);
details.push(
`Available trail: ${Math.round(duration / 60000)} min${entry.track.truncated || entry.trailTruncated ? ' (limited)' : ''}`,
entry.track.truncated || entry.trailTruncated
? t('Available trail: {minutes} min (limited)', { minutes })
: t('Available trail: {minutes} min', { minutes }),
);
}
return { title, details };
@@ -461,12 +516,12 @@ export const UNDERGROUND_MODES = new Set(['subway']);
/** The mode in plain words, for a card a person reads rather than scans. */
export const TRANSIT_MODE_WORD = Object.freeze({
bus: 'Bus',
tram: 'Tram',
subway: 'Subway',
rail: 'Train',
ferry: 'Ferry',
unknown: 'Transit vehicle',
bus: msg('Bus'),
tram: msg('Tram'),
subway: msg('Subway'),
rail: msg('Train'),
ferry: msg('Ferry'),
unknown: msg('Transit vehicle'),
});
/** Short, fixed-width mode word for the detection card's class field. */
+2
View File
@@ -8,6 +8,7 @@ import {
} from './policy.js';
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import { t } from '../../i18n/index.js';
/**
* Click-to-select one vehicle and a card that follows it.
@@ -60,6 +61,7 @@ export function createSelection({ state, services, parts }) {
now,
entry.fetchedAt,
entry,
t,
);
// The TEXT is what the throttle is for, and it is republished only when
// it has changed: the anchor is live through its getter, so re-sending
@@ -0,0 +1,75 @@
// K-37: the Transit selection card follows the UI language. The copy builder
// stays free of i18n (AI tools import policy.js); the browser hands it t().
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from '../../i18n/index.js';
import { buildTransitSelectionCopy } from './policy.js';
const feed = { name: 'Metro Transit', region: 'Minneapolis–St Paul, MN' };
const now = 1_788_936_960_000;
const record = {
id: 'T1',
label: 'T1',
routeId: 'Blue',
lat: 44.9,
lon: -93.4,
bearing: 248,
speedMps: 11.2,
timestamp: 1_788_936_900,
timestampSource: 'header',
stopId: '57458',
status: 'STOPPED_AT',
occupancy: 'FEW_SEATS_AVAILABLE',
};
test('en: the copy is unchanged with and without the i18n t()', () => {
setLocale('en');
const plain = buildTransitSelectionCopy(feed, record, 'subway', now);
assert.deepEqual(
buildTransitSelectionCopy(feed, record, 'subway', now, null, null, t),
plain,
);
assert.deepEqual(plain.details, [
'Subway · Metro Transit · Minneapolis–St Paul, MN',
'Reported 60 s ago (feed time)',
'reported 40 km/h · reported hdg 248°',
'Last report: stopped at stop 57458 · few seats available',
'Shown at street level · depth not in the feed',
'Vehicle T1',
]);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Transit card is in the UI language`, () => {
setLocale(locale);
try {
const copy = buildTransitSelectionCopy(
feed,
record,
'subway',
now,
null,
null,
t,
);
const text = [copy.title, ...copy.details].join(' | ');
for (const english of [
'Route',
'Subway',
'Reported',
'reported',
'Last report',
'few seats',
'street level',
'Vehicle',
'feed time',
])
assert.ok(
!text.includes(english),
`${locale}: "${english}" in ${text}`,
);
} finally {
setLocale('en');
}
});
}
+11 -2
View File
@@ -1,6 +1,7 @@
import {
accentForVesselType,
displayVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
@@ -127,10 +128,18 @@ export function createCards({
return `vessel:unkeyed:${name}:${lat}:${lon}`;
}
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
/**
* Uppercased AIS type for the card (empty string when unknown). Free feed
* text is bounded to the card width; a translated type token is a short
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
*/
function vesselTypeShort(record) {
return displayVesselType(record.type).toUpperCase().slice(0, 14);
const feedType = normalizeVesselType(record.type);
const shown = displayVesselType(record.type);
return shown === feedType
? feedType.toUpperCase().slice(0, 14)
: shown.toUpperCase();
}
/**
@@ -0,0 +1,87 @@
// C6: a vessel with an ambient card gets no detection callout under it (the
// card already shows name, speed and course); the speed unit of a callout
// follows the UI language.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import aisLiveVesselsLayer, {
_setVesselOverlayHostForTest,
_setVesselStateForTest,
_updateVesselCardsForTest,
} from '../../data/aisLiveVessels.js';
import { setLocale } from '../../i18n/index.js';
const record = (mmsi, name, screen, z = 0) => ({
name,
mmsi,
type: 'Cargo',
destination: '',
speed: 9.5,
course: 80,
heading: 80,
lastPositionUtc: '',
missedRefreshes: 0,
position: { x: 0, y: 0, z, screen },
billboard: null,
});
function detectable(cameraZ) {
const carded = record('200', 'GOLDEN BEAR', { x: 40, y: 30 });
const plain = record('300', 'BAY PILOT', { x: 41, y: 31 });
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) =>
position.screen;
_setVesselOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
_setVesselStateForTest({
viewer: {
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
camera: { positionWC: new Cesium.Cartesian3(0, 0, cameraZ) },
},
},
records: [carded, plain],
billboardCollection: { show: true, remove() {} },
});
try {
// Both share one 118 px grid cell, so only one of them gets a card.
_updateVesselCardsForTest([carded, plain]);
return Object.fromEntries(
aisLiveVesselsLayer
.getDetectableObjects()
.map((object) => [object.id, object]),
);
} finally {
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
_setVesselStateForTest({ enabled: false });
_setVesselOverlayHostForTest();
}
}
test('a vessel with a card on screen draws no detection label', () => {
const objects = detectable(2_500);
const hidden = Object.values(objects).filter((object) => object.hideLabel);
assert.equal(hidden.length, 1, 'exactly the carded vessel hides its label');
const shown = Object.values(objects).filter((object) => !object.hideLabel);
assert.equal(shown.length, 1, 'the vessel without a card keeps its label');
assert.equal(hidden[0].skipLabel, false, 'not treated as the tracked vessel');
});
test('past the card fade the detection label comes back', () => {
const objects = detectable(4_000_000);
assert.ok(Object.values(objects).every((object) => !object.hideLabel));
});
test('ru: the callout speed unit is translated', () => {
setLocale('ru');
try {
const objects = detectable(4_000_000);
assert.equal(objects['GOLDEN BEAR'].metric, '10 уз');
} finally {
setLocale('en');
}
assert.equal(detectable(4_000_000)['GOLDEN BEAR'].metric, '10 kn');
});
+5 -1
View File
@@ -1,6 +1,10 @@
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
import { viewAreaMovedEnough } from './viewArea.js';
// A portable export (package.json): no i18n here. The text stays English data
// and the layer panel shows it translated.
const msg = (text) => text;
/** Own source requests and classified feed state through explicit operations. */
export function createIngestion({
feed,
@@ -69,7 +73,7 @@ export function createIngestion({
ownsAisRequest(requestController, requestSessionId) &&
error?.name !== 'AbortError'
) {
markUnavailable(error?.message || 'AIS live load failed');
markUnavailable(error?.message || msg('AIS live load failed'));
console.warn('[Data:ais-live-vessels]', feed.error, error);
}
} finally {
+3 -2
View File
@@ -1,5 +1,5 @@
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js';
export function createLifecycle({
@@ -82,7 +82,7 @@ export function createLifecycle({
function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable');
state.feed.error = reason || 'AIS live load failed';
state.feed.error = reason || msg('AIS live load failed');
state.feed.stale = state.feed.count > 0;
}
@@ -129,6 +129,7 @@ export function createLifecycle({
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
state.activeLabelCount = 0;
state.cardedRecords = new Set();
state.selectedRecord = null;
state.trail = null;
state.trailPositions = [];
+7 -6
View File
@@ -4,6 +4,7 @@ export {
AIS_FIRST_CONNECT_LABEL,
} from './recordPolicy.js';
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
import { msg } from '../../i18n/index.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -71,12 +72,12 @@ export const DEFAULT_AIS_RUNTIME = Object.freeze({
*/
export const AIS_STATUS_REASON = {
'missing-key': 'AISSTREAM_API_KEY not set',
unsupported: 'live feed unsupported',
connecting: 'connecting to feed…',
closed: 'feed disconnected',
error: 'feed down',
idle: 'feed idle',
'missing-key': msg('AISSTREAM_API_KEY not set'),
unsupported: msg('live feed unsupported'),
connecting: msg('connecting to feed…'),
closed: msg('feed disconnected'),
error: msg('feed down'),
idle: msg('feed idle'),
};
/**
+16 -9
View File
@@ -60,20 +60,22 @@ export function createQueries({
if (status === 'auth-failed') {
// Actionable, not a countdown: retrying cannot fix a rejected credential,
// so the chip asks the operator to do the one thing that can.
return 'API key rejected — check AISSTREAM_API_KEY';
return t('API key rejected — check AISSTREAM_API_KEY');
}
if (status === 'stale') {
const silentSec = Math.round(Number(payload?.silentForMs) / 1000);
return Number.isFinite(silentSec) && silentSec > 0
? `feed silent ${silentSec}s — no AIS data`
: 'feed silent — no AIS data';
? t('feed silent {seconds}s — no AIS data', { seconds: silentSec })
: t('feed silent — no AIS data');
}
const attempt = Number(payload?.reconnectAttempt);
const suffix =
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
if (Number.isFinite(attempt) && attempt >= 1)
return status === 'down'
? t('feed down — retrying slowly (attempt {attempt})', { attempt })
: t('reconnecting to feed… (attempt {attempt})', { attempt });
return status === 'down'
? `feed down — retrying slowly${suffix}`
: `reconnecting to feed…${suffix}`;
? t('feed down — retrying slowly')
: t('reconnecting to feed…');
}
/**
@@ -106,7 +108,8 @@ export function createQueries({
typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: '';
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
// The reasons are English in policy.js (msg) and shown in the UI language.
const reason = t(AIS_STATUS_REASON[status] || msg('feed unavailable'));
return detail && !AIS_STATUS_REASON[status]
? `${reason} (${detail})`
: reason;
@@ -468,7 +471,7 @@ export function createQueries({
* @param {Object} [options={}] - Options from the detection system.
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Seed offset for stride sampling.
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, hideLabel: boolean}>}
*/
getDetectableObjects(options = {}) {
if (
@@ -489,6 +492,7 @@ export function createQueries({
const start = seed % stride;
const selected = state.selectedRecord;
const carded = state.cardedRecords;
const result = [];
for (let idx = 0; idx < records.length; idx += 1) {
if ((idx - start) % stride !== 0) continue;
@@ -503,6 +507,9 @@ export function createQueries({
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
// An open ambient card already names the vessel with its speed and
// course; a detection label under it would only cover them.
hideLabel: carded?.has(record) === true,
klass: record.type
? String(record.type).toUpperCase().slice(0, 14)
: undefined,
+28 -1
View File
@@ -1,10 +1,12 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
vesselTypeCss,
vesselOverlayCohortLimit,
VESSEL_OVERLAY_SOURCE_ID,
applyVesselOverlayPolicy,
VESSEL_CARD_FADE_DISTANCE_M,
VESSEL_CARD_FADE_START_RATIO,
} from '../../data/vesselLabels.js';
import {
cameraPoseSignature,
@@ -365,6 +367,7 @@ export function createRendering({
if (!scene || !records.length || maxLabels <= 0) {
state.activeLabelCount = entries.length;
state.cardedRecords = new Set();
publishVesselOverlayEntries(entries);
return;
}
@@ -402,6 +405,9 @@ export function createRendering({
if (screen) accepted.push({ x: screen.x, y: screen.y });
}
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
// Vessels with an ambient card: the card already shows name, speed and
// course, so the detection overlay draws no label of its own under it.
const carded = new Set();
for (const candidate of ranked) {
if (entries.length >= maxLabels) break;
if (
@@ -414,8 +420,10 @@ export function createRendering({
continue;
accepted.push({ x: candidate.x, y: candidate.y });
entries.push(components.cards.buildVesselCard(candidate.record));
if (cardFullyShown(scene, candidate.record)) carded.add(candidate.record);
}
state.activeLabelCount = entries.length;
state.cardedRecords = carded;
publishVesselOverlayEntries(entries);
}
@@ -448,7 +456,10 @@ export function createRendering({
: '';
return {
...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
name: card.title,
mmsi,
}),
activate: () => {
const record = state.records.byMmsi.get(mmsi);
if (!record) return false;
@@ -465,6 +476,22 @@ export function createRendering({
);
}
/**
* Whether the host paints this ambient card at full strength: inside the
* start of its distance fade (applyVesselOverlayPolicy). Farther out the
* card fades away and the detection label stays the vessel's only text.
*/
function cardFullyShown(scene, record) {
const camera = scene.camera?.positionWC;
const visual = getVisual(record);
const position = visual.billboard?.position || visual.position;
if (!camera || !position) return true;
return (
Cesium.Cartesian3.distance(camera, position) <
VESSEL_CARD_FADE_DISTANCE_M * VESSEL_CARD_FADE_START_RATIO
);
}
function labelPriority(record, selected) {
if (record === selected) return 100000;
let score = 0;
+2
View File
@@ -69,6 +69,8 @@ export function createVesselState({ source, services }) {
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
activeFocusCount: 0,
activeLabelCount: 0,
/** @type {Set<Object>} Records that have an ambient card this pass. */
cardedRecords: new Set(),
selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
+16 -1
View File
@@ -10,6 +10,21 @@ import {
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js';
/**
* A stored (English) failure in the language. HTTP failures from source.js
* and infraredImage.js carry their status in the text, so they are matched
* back onto a placeholder; anything else is a plain catalog key.
*/
function presentWeatherProblem(problem) {
const http = /^Weather (image )?HTTP (\d+)$/.exec(problem);
if (http) {
return http[1]
? t('Weather image HTTP {status}', { status: http[2] })
: t('Weather HTTP {status}', { status: http[2] });
}
return t(problem);
}
const STOPS = [
[10, '#00ecec'],
[20, '#0000f6'],
@@ -532,7 +547,7 @@ export function createWeatherLayer({
const diagnostic = rendering?.getDiagnostics();
// Errors are stored in English (stats, diagnostics); shown translated.
const problem = error || diagnostic?.error;
const shownProblem = problem ? t(problem) : null;
const shownProblem = problem ? presentWeatherProblem(problem) : null;
const shownHost = hostStatus ? t(hostStatus) : null;
const b = manifest?.bounds;
const canvas = viewer?.scene?.canvas;
+2 -1
View File
@@ -1,4 +1,5 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { msg } from '../../i18n/index.js';
import { weatherImageUrl } from './source.js';
import { infraredAlpha } from './infraredAlpha.js';
@@ -49,7 +50,7 @@ export function decodeInfraredImage(blob, signal) {
};
image.onerror = () => {
cleanup();
reject(new Error('Infrared decode failed'));
reject(new Error(msg('Infrared decode failed')));
};
signal.addEventListener('abort', abort, { once: true });
image.src = url;
+2 -1
View File
@@ -201,7 +201,8 @@ function createGlobeRendering({
await Promise.all(
urls.map(async (url) => {
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error('Weather prefetch unavailable');
if (!response.ok)
throw new Error(msg('Weather prefetch unavailable'));
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
}),
);
+7 -3
View File
@@ -1,5 +1,9 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// Shared with the AI tools, so no i18n import: failures stay English here and
// the weather layer translates them where it shows them (t / translateKnown).
const msg = (text) => text;
export const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
@@ -10,7 +14,7 @@ export const WEATHER_PRODUCTS = Object.freeze([
/** Only bounded, explicit observations may become imagery requests. */
export function validateWeatherSnapshot(value, product) {
if (!value || value.schemaVersion !== 1 || value.product !== product)
throw new Error('Malformed weather manifest');
throw new Error(msg('Malformed weather manifest'));
if (value.unavailable) return value;
const { bounds, times } = value;
if (
@@ -40,7 +44,7 @@ export function validateWeatherSnapshot(value, product) {
value.maxLevel !== 6 ||
value.tilingScheme !== 'geographic'
)
throw new Error('Malformed weather manifest');
throw new Error(msg('Malformed weather manifest'));
return value;
}
@@ -113,7 +117,7 @@ export function createWeatherSource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Weather request timed out')),
() => controller.abort(new Error(msg('Weather request timed out'))),
timeoutMs,
);
try {
+25 -5
View File
@@ -11,7 +11,7 @@ import {
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { t, msg } from '../../i18n/index.js';
import { t, msg, translateKnown } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
/** Format a forecast timestamp explicitly in UTC. */
@@ -22,6 +22,25 @@ export function formatWindValidTime(value) {
: null;
}
/*
* Reasons the server puts in the manifest (server/providers/wind.js). They stay
* English in data and are translated where they are shown.
*/
const SERVER_REASONS = new Set([msg('Wind upstream unavailable')]);
const reasonText = (reason) =>
SERVER_REASONS.has(reason) ? t(reason) : translateKnown(reason) || null;
/**
* A wind problem in the UI language. Source errors are English data; the
* HTTP status line carries a number, so it goes back onto a placeholder.
*/
function presentWindProblem(problem) {
const http = /^Wind HTTP (\d+)$/.exec(problem);
if (http)
return t('{source} HTTP {status}', { source: t('Wind'), status: http[1] });
return t(problem);
}
/** Forecast issue time is source age; valid time is reported separately. */
export function windStats(manifest) {
const run = Date.parse(manifest?.cycle?.runIso);
@@ -29,7 +48,8 @@ export function windStats(manifest) {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: Number.isFinite(run) ? run : null,
error:
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
manifest?.reason ||
(manifest?.unavailable ? msg('Wind unavailable') : null),
};
}
@@ -306,8 +326,8 @@ export function createWindLayer({
? t('Loading forecast')
: preparing
? t('Preparing flow')
: (error && t(error)) ||
manifest?.reason ||
: (error && presentWindProblem(error)) ||
reasonText(manifest?.reason) ||
imageryError ||
(scalarMissing ? t('Selected field unavailable') : null) ||
(manifest?.stale ? t('Cached forecast · stale') : null),
@@ -387,7 +407,7 @@ export function createWindLayer({
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
field: label,
unit: unitLabel(legendUnit),
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? `\n${t(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ` · ${t('STALE')}` : ''}${error ? `\n${presentWindProblem(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
infoTitle: t(
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
),
+12 -8
View File
@@ -1,5 +1,9 @@
import { readWindBody } from '../../sources/windBody.js';
// Imported by src/tools: no i18n here. The texts stay English data and the
// wind layer and layer panel show them translated.
const msg = (text) => text;
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -8,15 +12,15 @@ export function createWindSource({
return {
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
if (!['gfs', 'ifs'].includes(model))
throw new Error('Unknown wind model');
throw new Error(msg('Unknown wind model'));
if (!['none', 'temperature', 'pressure'].includes(overlay))
throw new Error('Unknown weather overlay');
throw new Error(msg('Unknown weather overlay'));
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Wind request timed out')),
() => controller.abort(new Error(msg('Wind request timed out'))),
timeoutMs,
);
const active = controller.signal;
@@ -42,8 +46,8 @@ export function createWindSource({
scalar === undefined &&
scalarError ===
(overlay === 'temperature'
? 'Temperature field unavailable'
: 'Mean sea level pressure field unavailable');
? msg('Temperature field unavailable')
: msg('Mean sea level pressure field unavailable'));
const scalarValid =
overlay === 'none'
? scalar === undefined && scalarError === undefined
@@ -73,7 +77,7 @@ export function createWindSource({
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
).test(manifest.gridUrl)
)
throw new Error('Malformed wind manifest');
throw new Error(msg('Malformed wind manifest'));
const count = grid.nx * grid.ny;
const expectedBytes = count * (scalar ? 12 : 8);
const gridResponse = await fetchImpl(manifest.gridUrl, {
@@ -84,10 +88,10 @@ export function createWindSource({
throw new Error(`Wind HTTP ${gridResponse.status}`);
const bytes = await readWindBody(gridResponse, expectedBytes, active);
if (bytes.byteLength !== expectedBytes)
throw new Error('Malformed wind grid');
throw new Error(msg('Malformed wind grid'));
const values = new Float32Array(bytes.buffer);
if (!values.every(Number.isFinite))
throw new Error('Malformed wind grid');
throw new Error(msg('Malformed wind grid'));
return {
...manifest,
u: values.slice(0, count),
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../../i18n/index.js';
import { createWindLayer } from './index.js';
test('stale wind forecast is marked in the UI language (K-40)', async () => {
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((n) => [
n,
() => {},
]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({
feed: {
getSnapshot: async () => ({
model: 'gfs',
grid: { nx: 2, ny: 2 },
cycle: {
runIso: '2026-09-14T12:00:00Z',
validIso: '2026-09-14T18:00:00Z',
},
stale: true,
reason: 'Wind upstream unavailable',
}),
},
createRendering: () => rendering,
});
layer.init({ container: {} });
layer.enable();
await layer.update();
try {
assert.match(layer.getRowControls().info, / · STALE(\n|$)/);
for (const [locale, word] of [
['es', 'DESACTUALIZADO'],
['ru', 'УСТАРЕЛ'],
]) {
setLocale(locale);
const info = layer.getRowControls().info;
assert.doesNotMatch(info, /STALE/);
assert.match(info, new RegExp(word));
}
} finally {
setLocale('en');
layer.destroy();
}
});
@@ -0,0 +1,65 @@
// K-38: wind source failures are English data (src/layers/wind/source.js) and
// are shown in the UI language; the HTTP line keeps its status number.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer } from './index.js';
import { setLocale } from '../../i18n/index.js';
function harness(problem) {
const rendering = Object.fromEntries(
[
'attach',
'start',
'stop',
'clear',
'destroy',
'setField',
'setOptions',
].map((name) => [name, () => {}]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({
feed: {
getSnapshot: async () => {
throw new Error(problem);
},
},
createRendering: () => rendering,
});
layer.init({ container: {} });
layer.enable();
return layer;
}
async function statusFor(problem) {
const layer = harness(problem);
await layer.update();
return layer.getRowControls().summary.status;
}
test('en: wind source failures read as before', async () => {
assert.equal(await statusFor('Wind HTTP 503'), 'Wind HTTP 503');
assert.equal(
await statusFor('Wind request timed out'),
'Wind request timed out',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: wind source failures are shown in the UI language`, async () => {
setLocale(locale);
try {
const http = await statusFor('Wind HTTP 503');
assert.notEqual(http, 'Wind HTTP 503');
assert.match(http, /503/);
for (const problem of [
'Wind request timed out',
'Malformed wind manifest',
'Malformed wind grid',
])
assert.notEqual(await statusFor(problem), problem);
} finally {
setLocale('en');
}
});
}
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, windStats } from './index.js';
import { setLocale, translateKnown } from '../../i18n/index.js';
import { windProxy } from '../../../server/providers/wind.js';
function harness(feed) {
const rendering = Object.fromEntries(
[
'attach',
'start',
'stop',
'clear',
'destroy',
'setField',
'setOptions',
].map((name) => [name, () => {}]),
);
rendering.getDiagnostics = () => ({});
const layer = createWindLayer({ feed, createRendering: () => rendering });
layer.init({ container: {} });
layer.enable();
return layer;
}
async function serverManifestOnUpstreamFailure() {
let handler;
const failing = async () => {
throw new Error('upstream down');
};
windProxy({ models: { gfs: failing, ifs: failing } }).configureServer({
middlewares: { use: (_path, fn) => (handler = fn) },
});
let body = '';
await handler(
{ method: 'GET', url: '/manifest?model=gfs&overlay=none' },
{ writeHead() {}, end: (b) => (body = b), once() {}, removeListener() {} },
);
return JSON.parse(body);
}
test('en: the upstream failure reason reads as before (K-17)', async () => {
const manifest = await serverManifestOnUpstreamFailure();
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
assert.equal(
layer.getRowControls().summary.status,
'Wind upstream unavailable',
);
});
for (const locale of ['es', 'ru']) {
test(`${locale}: upstream failure status is shown in the UI language`, async () => {
const manifest = await serverManifestOnUpstreamFailure();
assert.equal(manifest.reason, 'Wind upstream unavailable');
setLocale(locale);
try {
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
const status = layer.getRowControls().summary.status;
const stats = translateKnown(windStats(manifest).error);
assert.notEqual(status, 'Wind upstream unavailable');
assert.notEqual(stats, 'Wind upstream unavailable');
} finally {
setLocale('en');
}
});
test(`${locale}: a stale cached forecast is reported in the UI language`, async () => {
// Shape the server returns when a cached forecast is kept after a failure.
const manifest = {
model: 'gfs',
grid: { nx: 2, ny: 2 },
cycle: {
runIso: '2026-09-14T12:00:00Z',
validIso: '2026-09-14T18:00:00Z',
},
stale: true,
reason: 'Wind upstream unavailable',
};
setLocale(locale);
try {
const layer = harness({ getSnapshot: async () => manifest });
await layer.update();
const status = layer.getRowControls().summary.status;
assert.doesNotMatch(status, /[A-Za-z]{4,} upstream unavailable/);
} finally {
setLocale('en');
}
});
}