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

+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,