diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 82f32853f..6e1e1236c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3098,11 +3098,18 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. -- **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. The language switch in the Visual Presets dock - stores the choice and reloads, so nothing re-renders live. +- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → + `navigator.languages` → English. `?lang=` is not stored: it applies while it + stays in the address, and the copied share link drops it, so a recipient keeps + their own language. The language switch in the Visual Presets dock stores the + choice, removes `?lang=` (keeps it only where storage is blocked) and reloads, + so nothing re-renders live; it asks first when the reload would lose work + (unsaved CCTV calibration, the open cockpit, Draw marks on the board, + imported map files, an active voice session — each registered through + `registerUnsavedWork`). - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) - extracts every message and fails on missing, unused, placeholder-mismatched or + extracts every message and fails on missing, empty, unused, + placeholder-mismatched, still-English (plural forms included) or wrongly-shaped plural entries. - **Not translated by design:** layer ids, share-link and hash parameters, API fields, voice tool schemas, provider names, feed data (callsigns, vessel and diff --git a/docs/I18N.md b/docs/I18N.md index 8e9e46ee9..550c50d75 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -33,7 +33,7 @@ label.textContent = t(FEED_STATE_LABELS[state]); ``` -`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. +`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language switch asks before reloading. ## Rules @@ -58,14 +58,14 @@ Changing the English changes the key, so the old translation goes unused and the To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock. -A translation that is exactly the English ("Bloom" → "Bloom") is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. +An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`. ## Text the check reports as unmarked `npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. -- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`, `say`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `accessibilityLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. Fix it by marking the text. When the text must stay as it is, say so where it lives: diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 96a00b016..9eec06cc1 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/.json`, keye A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English. -The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. Switching reloads the page. +The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page. ## The check @@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla npm run i18n -- --prune # drop entries the code no longer uses ``` -`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation is still the English text. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`. +`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing or empty, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation, or any form of a plural entry, is still the English text. The `--missing` template is not a translation: its strings are empty and its plural forms are the English, so the check fails until every one is translated. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`. Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index b8a77a775..0f48152a3 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -11,7 +11,7 @@ // Messages are the English literals passed to t(), tn() and msg() in src/, // plus the markup opted in with data-i18n* in src/ui/templates and index.html. // It also lists UI text that is not marked at all (see findUnmarked). -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -62,23 +62,27 @@ function decodeHtml(text) { const normalize = (text) => text.replace(/\s+/g, ' ').trim(); function trackedFiles(...pathspecs) { - return execFileSync( - 'git', - [ - 'ls-files', - '--cached', - '--others', - '--exclude-standard', - '--', - ...pathspecs, - ], - { - cwd: ROOT, - encoding: 'utf8', - }, - ) - .split('\n') - .filter(Boolean); + return ( + execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + ...pathspecs, + ], + { + cwd: ROOT, + encoding: 'utf8', + }, + ) + .split('\n') + .filter(Boolean) + // The index still lists a file moved or deleted without git mv / git rm. + .filter((file) => existsSync(path.join(ROOT, file))) + ); } /** @@ -213,8 +217,8 @@ const JS_SINKS = [ /\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g, /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, - /\b(?:showToast|toast|setHint|announce)\(\s*/g, - /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, + /\b(?:showToast|toast|setHint|announce|say)\(\s*/g, + /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, ]; const MARKERS = /\b(?:t|tn|msg)\(/y; @@ -438,6 +442,15 @@ export function readSameAsEnglish(locale) { /** The English a message shows: its text without the "context|" prefix. */ const englishOf = (text) => text.slice(text.indexOf('|') + 1); +/** A translation with nothing to show: t() falls back to English for it. */ +const blank = (value) => value.trim() === ''; + +/** Words to read, beyond {placeholders}, codes and punctuation. */ +const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, '')); + +const STILL_ENGLISH = (text, locale) => + `"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`; + /** * Compare one catalog with the extracted messages. * @param {Set} [sameAsEnglish] Messages allowed to read as English. @@ -464,44 +477,72 @@ export function checkCatalog( errors.push(`"${text}" needs plural forms ${categories.join('/')}`); continue; } + const english = new Set([info.one, englishOf(text)]); + const stillEnglish = []; for (const category of categories) { - if (typeof entry[category] !== 'string') + const form = entry[category]; + if (typeof form !== 'string') errors.push(`"${text}" lacks the "${category}" form`); - else if (placeholders(entry[category]) !== placeholders(text)) - errors.push( - `"${text}" [${category}] placeholders differ: ${entry[category]}`, - ); + else if (blank(form)) + errors.push(`"${text}" [${category}] is empty: translate it`); + else if (placeholders(form) !== placeholders(text)) + errors.push(`"${text}" [${category}] placeholders differ: ${form}`); + else if (english.has(form) && hasLetters(form)) + stillEnglish.push(category); } + if (stillEnglish.length && !sameAsEnglish.has(text)) + errors.push( + `${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`, + ); } else if (typeof entry !== 'string') { errors.push(`"${text}" must be a string`); + } else if (blank(entry)) { + errors.push(`"${text}" is empty: translate it`); } else if (placeholders(entry) !== placeholders(text)) { errors.push(`"${text}" placeholders differ: ${entry}`); } else if ( entry === englishOf(text) && - /\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) && + hasLetters(entry) && !sameAsEnglish.has(text) ) { - errors.push( - `"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`, - ); + errors.push(STILL_ENGLISH(text, locale)); } } const stale = Object.keys(catalog).filter((text) => !messages.has(text)); return { missing, stale, errors }; } +/** + * What `--missing ` prints: every missing entry, ready to translate. + * A template, not a translation: the check rejects a string left empty and a + * plural form left as the English. + */ +export function missingTemplate(locale, catalog, messages) { + const categories = new Intl.PluralRules(locale).resolvedOptions() + .pluralCategories; + const out = {}; + for (const text of checkCatalog(locale, catalog, messages, new Set()) + .missing) { + const info = messages.get(text); + out[text] = info.plural + ? Object.fromEntries( + categories.map((category) => [ + category, + category === 'one' ? info.one : text, + ]), + ) + : ''; + } + return out; +} + function main(args) { const messages = extractMessages(); const missingFor = args.includes('--missing') ? args[args.indexOf('--missing') + 1] : null; if (missingFor) { - const catalog = readCatalog(missingFor); - const out = {}; - for (const text of checkCatalog(missingFor, catalog, messages).missing) { - const info = messages.get(text); - out[text] = info.plural ? { one: info.one, other: text } : ''; - } + const out = missingTemplate(missingFor, readCatalog(missingFor), messages); console.log(JSON.stringify(out, null, 2)); return 0; } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3a65b0c67..a02bcd40c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -439,6 +439,7 @@ "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", + "src/ui/layerNames.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -467,6 +468,7 @@ "src/ui/location.js", "src/ui/locationControls.js", "src/ui/locationSearch.js", + "src/ui/placeNames.js", "src/locationStatus.js", "src/app/stateChannel.js", "src/i18n/index.js", @@ -527,7 +529,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/layerNames.js" ], "external": [] }, @@ -581,6 +584,7 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/cyberSonar.js", @@ -602,6 +606,7 @@ "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/scenePick.js", "src/keySetupCore.mjs", "src/localRequestGate.mjs", @@ -719,7 +724,8 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/ui/unitLabels.js", - "src/ui/keyGuidance.js" + "src/ui/keyGuidance.js", + "src/ui/layerNames.js" ], "external": ["cesium"] }, @@ -786,6 +792,7 @@ "src/data/bhoteKoshiFloodPath.js", "src/maps/catalog.js", "src/scenes/director.js", + "src/scenes/sceneSaveError.js", "src/scenes/playbackAdapter.js", "src/director/playback.js", "src/scenes/recipes.js", @@ -1078,7 +1085,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] }, @@ -1119,6 +1127,7 @@ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", + "src/data/installationNames.js", "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", @@ -1160,6 +1169,7 @@ "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1219,6 +1229,7 @@ "src/sources/capability.js", "src/sources/httpBody.js", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", @@ -1537,6 +1548,9 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", + "src/ui/layerNames.js", + "src/ui/unitLabels.js", + "src/ui/placeNames.js", "src/voice/narration.js", "src/voice/earcon.js", "src/voice/pointerContext.js", @@ -1568,6 +1582,7 @@ "./standalone/catalog" ], "modules": [ + "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", "src/hudLayouts.js", @@ -1710,6 +1725,7 @@ "src/data/meshFloorSampler.js", "src/data/militaryAwareness.js", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/militaryFlights.js", "src/data/militaryInstallationData.js", "src/data/militaryInstallations.js", @@ -2005,6 +2021,7 @@ "src/renderGovernor.js", "src/rightRailPolicy.js", "src/scenes/director.js", + "src/scenes/sceneSaveError.js", "src/scenes/playbackAdapter.js", "src/director/playback.js", "src/scenes/recipes.js", @@ -2123,6 +2140,7 @@ "src/voice/control.js", "src/voice/gevActions.js", "src/voice/speech.js", + "src/ui/layerNames.js", "src/voice/earcon.js", "src/voice/narration.js", "src/voice/turnMetrics.js", @@ -2156,6 +2174,7 @@ "src/ui/geoFileImport.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/data/directions.js", "src/app/layers/directions.js", @@ -2486,6 +2505,7 @@ "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/militaryInstallationData.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", @@ -2767,6 +2787,7 @@ "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/services/terrainHeights.js", "src/data/terrainHeights.js", @@ -3016,7 +3037,11 @@ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", - "src/data/cameraArrival.js" + "src/data/cameraArrival.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/index.js" ], "external": ["cesium"] }, @@ -3170,6 +3195,7 @@ "exports": ["./layers/directions"], "modules": [ "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/data/routeSteps.js", "src/i18n/index.js", @@ -3212,6 +3238,7 @@ "voice-session-controls": { "exports": ["./voice/session-controls"], "modules": [ + "src/ui/placeNames.js", "src/voice/sessionCommands.js", "src/voice/session.js", "src/voice/control.js", @@ -3221,6 +3248,8 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", + "src/ui/layerNames.js", + "src/ui/unitLabels.js", "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -3271,6 +3300,7 @@ "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", + "src/data/installationNames.js", "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", @@ -3312,6 +3342,7 @@ "src/sources/overpassRoads.js", "src/sources/httpBody.js", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", @@ -3496,7 +3527,11 @@ "scripts/qa-transit-controls.mjs", "scripts/qa-transit-browser.mjs", "src/layers/transit/source.js", - "src/sources/transitHistory.js" + "src/sources/transitHistory.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "pbf"] }, @@ -3670,6 +3705,7 @@ "src/data/retryableLoad.js", "src/data/local_data/osm_military_names/names.json", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", "src/data/tomtomTiles.js", diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs new file mode 100644 index 000000000..abf92fc86 --- /dev/null +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a + * file dropped from the desktop, proved in a real browser. + * + * The dialog replaces the browser-drawn "Choose files / No file chosen" line + * with a translated one (src/ui/sceneSharing.js, fileChooser). The native + * input must stay the whole control underneath it: a shrunken 1 px input + * accepts drops only on its own pixel, so dragging a data-pack file onto the + * visible row selected nothing. This harness drops a file onto the centre of + * the visible row through CDP and expects `input.files` to hold it. + * + * Usage: + * node scripts/qa-scene-dialog-file-drop.mjs + * + * Needs the repository's puppeteer (devDependencies). Exit status 0 when the + * drop selects the file, 1 otherwise. + */ +import http from 'node:http'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TYPES = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.html': 'text/html', +}; + +const PAGE = ` +`; + +async function dropOn(client, x, y, file) { + const data = { items: [], files: [file], dragOperationsMask: 1 }; + for (const type of ['dragEnter', 'dragOver', 'drop']) { + await client.send('Input.dispatchDragEvent', { type, x, y, data }); + } +} + +async function main() { + const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-')); + const pack = path.join(dir, 'pack.json'); + await writeFile(pack, '{}'); + const server = http.createServer(async (req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(PAGE); + return; + } + try { + const body = await readFile(path.join(ROOT, req.url)); + res.writeHead(200, { + 'content-type': TYPES[path.extname(req.url)] || 'text/plain', + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/`; + const browser = await puppeteer.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1000, height: 800 }); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + await page.goto(url); + await page.waitForFunction(() => window.ready === true); + const point = await page.evaluate(() => window.openDialog()); + const client = await page.createCDPSession(); + await dropOn(client, point.x, point.y, pack); + const names = await page.evaluate(() => + [...window.filesInput.files].map((file) => file.name), + ); + console.log(JSON.stringify({ names, errors })); + if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`); + if (names.length !== 1 || names[0] !== 'pack.json') { + throw new Error( + `expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`, + ); + } + console.log('OK: a file dropped on the visible control is selected'); + } finally { + await browser.close(); + server.close(); + await rm(dir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index fede1aade..1c441c91a 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json'; const GIS = 'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer'; const HOUR = 3600_000; +// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in +// msg() and translates it when shown (likewise 'Cyclone data unavailable'). const COVERAGE = 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.'; const LAYERS = [ diff --git a/server/providers/wind.js b/server/providers/wind.js index e953ffca3..c94fe3979 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js'; +// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact +// English with msg() and translates it when shown, so keep the two in sync. +const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable'; + /** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */ export function windProxy({ fetchImpl = fetch, @@ -25,7 +29,7 @@ export function windProxy({ ...(overlay === 'none' ? {} : { overlay }), unavailable: true, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }, }); const sendJson = (res, value, status = 200) => { @@ -139,7 +143,7 @@ export function windProxy({ old.manifest = { ...old.manifest, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }; return old; } diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index b8be4c036..84277c3ac 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -134,6 +134,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }; // The gate itself is pure and unit-tested (admitKeySetupRequest in // src/keySetupCore.mjs) — this just feeds it the request. + // A refusal as the panel reads it: `code` (and `params`) pick its localized + // sentence in src/keySetup.js; `error` is the English for logs and scripts. + const refusal = ({ error, code, params }) => ({ error, code, params }); const admit = (req) => admitKeySetupRequest({ method: req.method, @@ -247,18 +250,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { configureServer(server) { server.middlewares.use('/api/setup/status', (req, res) => { if (req.method !== 'GET') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); respond(res, 200, providerStatus()); }); server.middlewares.use('/api/setup/keys', (req, res) => { if (req.method !== 'POST') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); let body = ''; let overflowed = false; req.on('data', (chunk) => { @@ -270,15 +279,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }); req.on('end', () => { if (overflowed) - return respond(res, 413, { error: 'Request too large' }); + return respond(res, 413, { + error: 'Request too large', + code: 'too-large', + }); let parsed; try { parsed = JSON.parse(body || '{}'); } catch { - return respond(res, 400, { error: 'Invalid JSON' }); + return respond(res, 400, { + error: 'Invalid JSON', + code: 'invalid-json', + }); } const verdict = validateKeySetupUpdates(parsed); - if (!verdict.ok) return respond(res, 400, { error: verdict.error }); + if (!verdict.ok) return respond(res, 400, refusal(verdict)); // Neither a replace NOR a removal may touch an externally-supplied // credential (shell env, Keychain, another workflow). This backs the // UI's read-only "configured externally" state with a real contract — @@ -289,6 +304,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { if (isExternallyManaged(name, inStore)) { return respond(res, 409, { error: `${name} is configured outside Provider Settings and can only be changed where it was set`, + code: 'external', + params: { name }, }); } } @@ -305,10 +322,16 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { ) { return respond(res, 500, { error: `The key was not saved: ${error.message}`, + code: + error.code === 'GEV_HARDEN_FAILED' + ? 'harden-failed' + : 'store-unreadable', }); } return respond(res, 500, { error: `Could not write the ${storeName()} store`, + code: 'write-failed', + params: { store: storeName() }, }); } // Live for the server-side proxies immediately; the restart below is diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 66743c65b..91adb4d0d 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -36,6 +36,7 @@ import { pointerOwner, releasePointer, } from '../data/inputOwnership.js'; +import { pointerOwnerName } from '../data/pointerOwnerNames.js'; import { DRAW_SHAPES, MAX_VERTICES, @@ -48,7 +49,7 @@ import { removeLastVertex, } from './drawMode.js'; -import { t } from '../i18n/index.js'; +import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ export const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; @@ -101,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) { target.addEventListener(type, listener, options); domListeners.push([target, type, listener, options]); }; + // The board lives only in memory: switching language (a reload) asks first + // while it holds a lasting mark or a shape is half drawn. + const unregisterUnsavedWork = registerUnsavedWork(() => { + if (destroyed) return null; + const lasting = (annotations.list?.() || []).some( + (mark) => mark && mark.ttlMs == null && !mark.expiring, + ); + return lasting || session?.vertices?.length > 0 + ? msg('drawings on the map') + : null; + }); const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME); // `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would @@ -334,7 +346,7 @@ export function initDrawTool({ viewer, annotations }) { // half-starting. setHint( t('{owner} is using the pointer — close it first.', { - owner: pointerOwner(), + owner: translateKnown(pointerOwnerName(pointerOwner())), }), ); return active; @@ -514,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) { generation += 1; if (active) setActive(false); destroyed = true; + unregisterUnsavedWork(); releaseSceneHandler(); releasePointer(lease); lease = null; diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 6da4c91af..8a8558cb2 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -6,6 +6,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { initDrawTool } from './drawTool.js'; +import { describeUnsavedWork } from '../i18n/index.js'; import { isPointerFree, pointerOwner, @@ -240,6 +241,30 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; /* ── the tests ─────────────────────────────────────────────────────────── */ +// K-27. First in the file: the tools other tests leave behind would answer the +// same registry. +test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => { + const h = harness(); + try { + let marks = []; + h.annotations.list = () => marks; + assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing'); + marks = [{ id: 'a', ttlMs: 8000, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work'); + marks = [{ id: 'b', ttlMs: null, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), ['drawings on the map']); + marks = []; + h.element('draw-toggle').emit('click'); + h.tool.addVertex(-97.75, 30.26); + assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress'); + marks = [{ id: 'c', ttlMs: null, expiring: false }]; + await h.tool.destroy(); + assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters'); + } finally { + h.restore(); + } +}); + test('turning Draw on takes a lease and turning it off gives it back', async () => { const h = harness(); try { @@ -263,7 +288,11 @@ test('Draw refuses to start when something else holds the pointer', async () => h.element('draw-toggle').emit('click'); assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); - assert.match(h.element('draw-hint').textContent, /directions/); + // Named by its tool, not by the internal owner id (E3/E12). + assert.equal( + h.element('draw-hint').textContent, + 'Directions is using the pointer — close it first.', + ); assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); releasePointer(other); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 464bcbac3..ce8250624 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -569,15 +569,20 @@ export function panelRuntime(config) { queued = view; if (started) return; started = true; + // i18n-ignore: this script runs as source text in the host panel, without the app catalogs say("Loading God's Eye View…"); startApp(url, view).catch((error) => + // i18n-ignore: this script runs as source text in the host panel, without the app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); setTimeout(() => { + // i18n-ignore: this script runs as source text in the host panel if (!ready) say( + // i18n-ignore: see above "God's Eye View did not load here" + (blocked ? ` (${blocked})` : '') + + // i18n-ignore: see above ". Use Open in God's Eye View above.", ); }, config.loadTimeoutMs); @@ -658,6 +663,7 @@ export function panelRuntime(config) { }, (error) => say( + // i18n-ignore: this script runs as source text in the host panel, without the app catalogs "This client did not accept the God's Eye View panel" + (error?.message ? `: ${error.message}` : '.'), ), diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 19d1b2455..25bb05afa 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -9,6 +9,16 @@ const CONTEXT_DEPENDENCIES = Object.freeze({ 'space-missions': new Set(['rocket-launches', 'satellites']), }); const CONTEXT_COMPANIONS = new Set(['radio']); +// This module is shared with voice actions and scene policy, so the refusal +// reason stays English data; the Context shell translates it with t() at the +// toast (src/ui/contextLayerChanges.js). +const msg = (text) => text; +/** The Space Missions refusal, with the blocked layer's name as {layer}. */ +export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg( + 'Space Missions isolates replay data. Exit the mode to enable {layer}.', +); +const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] = + SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}'); /** Return whether an origin represents a direct user choice on this route. */ export function isExplicitUserIntentOrigin(origin, layerId = null) { return origin === 'user' || origin === 'voice'; @@ -336,7 +346,30 @@ export function contextLayerEnableBlockReason({ return null; } const label = String(layerName || change.layerId || 'that layer'); - return `Space Missions isolates replay data. Exit the mode to enable ${label}.`; + return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label); +} + +/** + * Split a Space Missions refusal back into its template and layer name so + * the presentation can translate the sentence and localize the name. + * @param {string} reason + * @returns {{layer: string}|null} The blocked layer's name, or null when the + * reason is not a Space Missions refusal. + */ +export function parseContextLayerBlockReason(reason) { + if ( + typeof reason !== 'string' || + !reason.startsWith(BLOCK_REASON_PREFIX) || + !reason.endsWith(BLOCK_REASON_SUFFIX) || + reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length + ) + return null; + return { + layer: reason.slice( + BLOCK_REASON_PREFIX.length, + reason.length - BLOCK_REASON_SUFFIX.length, + ), + }; } /** diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js index ad31268f5..5d4e8ce7d 100644 --- a/src/creditKeyboard.js +++ b/src/creditKeyboard.js @@ -2,7 +2,7 @@ * Adds the keyboard semantics omitted by Cesium's href-less attribution links. * Cesium retains ownership of showing and hiding its required credit lightbox. */ -import { t } from './i18n/index.js'; +import { getLocale, t } from './i18n/index.js'; export function configureCreditKeyboardAccess(root = document) { const expand = root?.querySelector?.( @@ -13,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) { if (!expand || !lightbox || !close) return false; const overlay = lightbox.parentElement; + // Cesium writes these two texts in English once, when it builds the credit + // display, and never rewrites them; English keeps Cesium's own text. + if (getLocale() !== 'en') { + expand.textContent = t('Data attribution'); + const title = lightbox.querySelector?.('.cesium-credit-lightbox-title'); + if (title) title.textContent = t('Data provided by:'); + } + if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox'; expand.setAttribute('role', 'button'); expand.setAttribute('tabindex', '0'); diff --git a/src/creditKeyboard.test.mjs b/src/creditKeyboard.test.mjs index 3662936aa..6a0780ada 100644 --- a/src/creditKeyboard.test.mjs +++ b/src/creditKeyboard.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { configureCreditKeyboardAccess } from './creditKeyboard.js'; +import { setLocale } from './i18n/index.js'; function fakeElement() { const attributes = new Map(); @@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup' assert.equal(f.overlay.listenerCount('click'), 1); assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false); }); + +function textFixture() { + const f = fixture(); + const title = fakeElement(); + title.textContent = 'Data provided by:'; + f.expand.textContent = 'Data attribution'; + f.lightbox.querySelector = (selector) => + selector === '.cesium-credit-lightbox-title' ? title : f.close; + return { ...f, title }; +} + +test('English keeps Cesium attribution texts', () => { + setLocale('en'); + const f = textFixture(); + configureCreditKeyboardAccess(f.root); + assert.equal(f.expand.textContent, 'Data attribution'); + assert.equal(f.title.textContent, 'Data provided by:'); +}); + +for (const [locale, link, title] of [ + ['ru', 'Источники данных', 'Данные предоставлены:'], + ['es', 'Atribución de datos', 'Datos proporcionados por:'], +]) + test(`${locale}: Cesium attribution link and lightbox title are translated`, () => { + setLocale(locale); + try { + const f = textFixture(); + configureCreditKeyboardAccess(f.root); + assert.equal(f.expand.textContent, link); + assert.equal(f.title.textContent, title); + } finally { + setLocale('en'); + } + }); diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index a6bbe3c70..4e9f10794 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -438,18 +438,22 @@ export function classifyAircraft({ typeCode, category } = {}) { return 'airliner'; } -/** Readable name of each class, for cards and readouts. */ +// Portable flight records import this module, so it has no i18n: msg() only +// marks the labels, and the card translates them where it shows them. +const msg = (text) => text; + +/** Readable name of each class, for cards and readouts (English data). */ export const AIRCRAFT_CLASS_LABELS = Object.freeze({ - light: 'Light aircraft', - glider: 'Glider', - turboprop: 'Turboprop', - airliner: 'Airliner', - widebody: 'Widebody', - quadjet: 'Four-engine jet', - helicopter: 'Helicopter', - fastjet: 'Fast jet', - bizjet: 'Business jet', - uav: 'Drone', + light: msg('Light aircraft'), + glider: msg('Glider'), + turboprop: msg('Turboprop'), + airliner: msg('Airliner'), + widebody: msg('Widebody'), + quadjet: msg('Four-engine jet'), + helicopter: msg('Helicopter'), + fastjet: msg('Fast jet'), + bizjet: msg('Business jet'), + uav: msg('Drone'), }); /** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */ diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 4bf7fe7b3..2a2973610 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -729,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) { return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`; } +// The pack names the kind of source in `media.publisher` (event.json); the +// frame shows it in the UI language. Any other publisher shows as given. +const EVIDENCE_PUBLISHER_TEXT = new Set([ + msg('YouTube source'), + msg('Facebook source'), +]); + +/** An evidence item's publisher as its frame shows it. */ +function evidencePublisherText(publisher) { + const text = String(publisher); + return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text); +} + /** Repaint one stable frame with a poster or the current source-video frame. */ export function updateEvidenceFrame( canvas, @@ -790,20 +803,73 @@ export function updateEvidenceFrame( infoY + 27, ); ctx.fillStyle = WORLD_OVERLAY_STYLE.detail; - ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail); - const publisher = String( - observation.media?.publisher || t('SOURCE LINK'), + const publisher = ( + observation.media?.publisher + ? evidencePublisherText(observation.media.publisher) + : t('SOURCE LINK') ).toUpperCase(); ctx.fillText( - t('{publisher} / OPEN', { - publisher: ellipsizeEvidenceText(publisher, 24), - }), + fitEvidenceSourceLine( + ctx, + publisher, + scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail), + canvas.width - 32, + ), 16, infoY + 53, ); return true; } +// The smallest the source line's font may shrink to before the publisher is +// shortened instead. +const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8; + +/** + * Set the font for the «{publisher} / OPEN» line and return the line so it + * fits `maxWidth`: a translation («ИСТОЧНИК YOUTUBE / ОТКРЫТЬ») is longer than + * the English and the portrait card cut its action word. A line that fits is + * drawn exactly as before; one that does not first shrinks its font (to 80%), + * then shortens the publisher — the «/ OPEN» action always stays whole. + * @param {CanvasRenderingContext2D} ctx + * @param {string} publisher Display publisher, already upper-cased. + * @param {string} font CSS font with a px size. + * @param {number} maxWidth + * @returns {string} + */ +function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) { + const line = (limit) => + t('{publisher} / OPEN', { + publisher: ellipsizeEvidenceText(publisher, limit), + }); + let limit = 24; + let text = line(limit); + ctx.font = font; + if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text; + const width = () => Number(ctx.measureText(text)?.width) || 0; + if (width() <= maxWidth) return text; + const size = /(\d+(?:\.\d+)?)px/.exec(font); + if (size) { + const base = Number(size[1]); + const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE; + // Width scales with the font size; step down until it fits or bottoms out. + let next = Math.max( + floor, + Math.floor(((base * maxWidth) / width()) * 10) / 10, + ); + for (;;) { + ctx.font = font.replace(size[0], `${next}px`); + if (width() <= maxWidth || next <= floor) break; + next = Math.max(floor, next - 0.5); + } + } + while (width() > maxWidth && limit > 4) { + limit = Math.min(limit, publisher.length) - 1; + text = line(limit); + } + return text; +} + /** Compose source imagery and caveats into one stable renderer-owned frame. */ export function createEvidenceFrame(observation, poster = null, options = {}) { const canvas = evidenceFrameCanvas(observation); diff --git a/src/data/bhoteKoshiEvidenceFit.test.mjs b/src/data/bhoteKoshiEvidenceFit.test.mjs new file mode 100644 index 000000000..cd4e0af50 --- /dev/null +++ b/src/data/bhoteKoshiEvidenceFit.test.mjs @@ -0,0 +1,121 @@ +// Proposal 5 / E4: the evidence frame's «{publisher} / OPEN» line is drawn on +// a canvas, so CSS cannot widen it; on the portrait card the Russian +// «ИСТОЧНИК YOUTUBE / ОТКРЫТЬ» lost its last letter. The line is measured and +// shrunk to fit, keeping the action word whole; English draws as before. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { setLocale } from '../i18n/index.js'; +import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js'; + +const eventUrl = new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, +); + +// JetBrains Mono advances every glyph by 0.6 em. +const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]); +const measure = (text, font) => text.length * 0.6 * fontPx(font); + +function drawSourceLine(observation, publisher) { + const layout = evidenceCardLayout(observation); + const drawn = []; + const context = { + font: '', + beginPath() {}, + clearRect() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + fillRect() {}, + drawImage() {}, + measureText(text) { + return { width: measure(String(text), this.font) }; + }, + fillText(value, x) { + drawn.push({ text: String(value), font: this.font, x }); + }, + }; + const canvas = { + width: layout.canvasWidth, + height: layout.canvasHeight, + _bhoteEvidenceLayout: layout, + getContext: () => context, + }; + const subject = publisher + ? { ...observation, media: { ...observation.media, publisher } } + : observation; + assert.equal(updateEvidenceFrame(canvas, subject), true); + const line = drawn.find((entry) => entry.text.includes(' / ')); + assert.ok(line, 'source line drawn'); + return { ...line, maxWidth: layout.canvasWidth - 32 }; +} + +async function portraitObservations() { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const portrait = event.evidenceSpine.filter( + (o) => evidenceCardLayout(o).kind === 'portrait', + ); + const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source'); + const facebook = portrait.find( + (o) => o.media?.publisher === 'Facebook source', + ); + assert.ok(youtube, 'a portrait YouTube card exists'); + return [youtube, facebook].filter(Boolean); +} + +test('English source lines draw unchanged on every card', async () => { + setLocale('en'); + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + for (const observation of event.evidenceSpine) { + const line = drawSourceLine(observation); + assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`); + assert.match( + line.text, + /^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/, + ); + } +}); + +test('ru/es source lines fit the portrait card with the action word whole', async () => { + const observations = await portraitObservations(); + try { + for (const [locale, action] of [ + ['ru', '/ ОТКРЫТЬ'], + ['es', '/ ABRIR'], + ]) { + setLocale(locale); + for (const observation of observations) { + const line = drawSourceLine(observation); + assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`); + assert.ok( + measure(line.text, line.font) <= line.maxWidth, + `${locale}: «${line.text}» at ${line.font} overflows`, + ); + assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable'); + } + } + setLocale('ru'); + const ru = drawSourceLine(observations[0]); + assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole'); + } finally { + setLocale('en'); + } +}); + +test('a publisher too long even at the smallest font is shortened, not the action', async () => { + const [observation] = await portraitObservations(); + try { + setLocale('ru'); + const line = drawSourceLine( + observation, + 'A very long regional broadcaster', + ); + assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text); + assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text); + assert.equal(fontPx(line.font), 21 * 0.8); + } finally { + setLocale('en'); + } +}); diff --git a/src/data/bhoteKoshiEvidencePublisher.test.mjs b/src/data/bhoteKoshiEvidencePublisher.test.mjs new file mode 100644 index 000000000..19591d83e --- /dev/null +++ b/src/data/bhoteKoshiEvidencePublisher.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +// K-30: the publisher on the evidence frame ("YouTube source") is pack text +// and is shown in the UI language; English stays "YOUTUBE SOURCE / OPEN". +import { setLocale } from '../i18n/index.js'; +import { createEvidenceFrame } from './bhoteKoshiEvent.js'; + +const eventUrl = new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, +); + +function captureFrameText(observation) { + const originalDocument = globalThis.document; + const text = []; + globalThis.document = { + createElement() { + const context = { + beginPath() {}, + clearRect() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + fillRect() {}, + fillText(value) { + text.push(String(value)); + }, + drawImage() {}, + }; + return { width: 0, height: 0, getContext: () => context }; + }, + }; + try { + assert.ok(createEvidenceFrame(observation)); + } finally { + globalThis.document = originalDocument; + } + return text; +} + +test('evidence card source line is fully in the UI language (es, ru)', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const youtube = event.evidenceSpine.find( + (o) => o.media?.publisher === 'YouTube source', + ); + const facebook = event.evidenceSpine.find( + (o) => o.media?.publisher === 'Facebook source', + ); + assert.ok(youtube && facebook); + try { + for (const locale of ['es', 'ru']) { + setLocale(locale); + for (const observation of [youtube, facebook]) { + const lines = captureFrameText(observation); + const sourceLine = lines.find((line) => line.includes(' / ')); + assert.ok(sourceLine, 'source line drawn'); + assert.doesNotMatch( + sourceLine, + /SOURCE/, + `${locale}: English "SOURCE" left in "${sourceLine}"`, + ); + } + } + } finally { + setLocale('en'); + } +}); + +test('evidence card source line keeps its English wording', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const youtube = event.evidenceSpine.find( + (o) => o.media?.publisher === 'YouTube source', + ); + setLocale('en'); + const sourceLine = captureFrameText(youtube).find((line) => + line.includes(' / '), + ); + assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN'); +}); diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 15757b99c..29a882f14 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -574,8 +574,9 @@ export function createBhoteKoshiLocatorLayer({ protected: true, active: true, interactive: true, - accessibilityLabel: + accessibilityLabel: t( 'Open the USGS initial report for the 26 August 2026 Nepal landslide', + ), activate: () => { const opened = window.open( TRIGGER_SOURCE_URL, diff --git a/src/data/detection.js b/src/data/detection.js index 5c5e90396..46926cf09 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1450,6 +1450,9 @@ function _drawOverlay(frame) { if (bracketAlpha > 0) protectedVisibleCount++; continue; } + // The source shows its own card for this object (e.g. an ambient vessel + // card): keep the bracket, draw no callout over the card. + if (obj.hideLabel) continue; const primary = String(obj.id || ''); const micro = String(obj.metric || ''); diff --git a/src/data/detectionDraw.js b/src/data/detectionDraw.js index db088ef5e..4b2e96251 100644 --- a/src/data/detectionDraw.js +++ b/src/data/detectionDraw.js @@ -8,6 +8,7 @@ * GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and * supplies the actual drawing surface. */ +import { formatNumber, t } from '../i18n/index.js'; /** @constant {number} Max characters shown for a label's primary (id) line. */ const MAX_PRIMARY = 18; @@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) { } /** - * Formats a speed in knots as a short rounded label. Returns '' for - * non-positive or non-finite speeds (declutters moored/unknown vessels). + * Formats a speed in knots as a short rounded label in the UI language. + * Returns '' for non-positive or non-finite speeds (declutters moored/unknown + * vessels). * @param {number} knots - Speed over ground in knots. - * @returns {string} e.g. '14 kn', or '' when not applicable. + * @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable. */ export function formatKnots(knots) { if (!Number.isFinite(knots) || knots <= 0) return ''; - return Math.round(knots) + ' kn'; + return t('{speed} kn', { speed: formatNumber(Math.round(knots)) }); } /** diff --git a/src/data/detectionDraw.test.mjs b/src/data/detectionDraw.test.mjs index a7f896971..ea9889596 100644 --- a/src/data/detectionDraw.test.mjs +++ b/src/data/detectionDraw.test.mjs @@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);} } }); + +test('formatKnots writes the unit in the UI language', async () => { + const { setLocale } = await import('../i18n/index.js'); + setLocale('ru'); + try { + assert.equal(formatKnots(9.5), '10 уз'); + } finally { + setLocale('en'); + } + assert.equal(formatKnots(9.5), '10 kn'); +}); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 06bfe01af..d9660cbae 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -955,6 +955,52 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share } }); +test('hideLabel keeps the bracket but paints no callout over the source card', () => { + // C6: an ambient vessel card already names the vessel with its speed; the + // callout drawn beside it covered that card's speed and course. + const env = installEnvironment(); + const objects = [ + { + position: new Cesium.Cartesian3(0, 0, 6_356_752), + sourceId: '200', + id: 'GOLDEN BEAR', + type: 'VEH', + metric: '10 kn', + hideLabel: true, + }, + { + position: new Cesium.Cartesian3(0.5, 0.5, 6_356_752), + sourceId: '300', + id: 'BAY PILOT', + type: 'VEH', + metric: '4 kn', + }, + ]; + try { + initWorldOverlay(env.viewer); + initDetection( + env.viewer, + [{ id: 'transit', getDetectableObjects: () => objects }], + () => {}, + ); + setMode('DENSE'); + env.advance(250); + env.postRender.raise(); + env.advance(250); + env.postRender.raise(); + const painted = env.ctx.calls + .filter(([name]) => name === 'fillText') + .map(([, text]) => String(text)); + assert.ok(painted.includes('BAY PILOT'), `painted ${JSON.stringify(painted)}`); + assert.ok( + !painted.some((text) => text.includes('GOLDEN BEAR')), + `no callout under the card; painted ${JSON.stringify(painted)}`, + ); + } finally { + env.cleanup(); + } +}); + test('transit contacts take the vehicle tier, not the civil-air one', () => { assert.equal(resolveTier({ type: 'VEH' }), 'vehicle'); assert.equal(resolveTier({ type: 'AIR' }), 'civil'); diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 785cf87ef..4c9b54bd8 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -740,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it', pointerBlockedMessage('measure', 'a'), 'measure is active — turn measure off, then set A', ); + // A registered owner without an exit is named by its tool, not its id. + assert.equal( + pointerBlockedMessage('recent-imagery-box', 'a'), + 'Recent imagery is active — turn Recent imagery off, then set A', + ); // Nothing to say when nothing is in the way, or when we are the holder. assert.equal(pointerBlockedMessage(null, 'a'), null); assert.equal(pointerBlockedMessage('', 'a'), null); diff --git a/src/data/installationFeedback.js b/src/data/installationFeedback.js index a04181922..bd2900087 100644 --- a/src/data/installationFeedback.js +++ b/src/data/installationFeedback.js @@ -1,25 +1,45 @@ -/** Explain mapped-site availability without claiming an unobserved overload. */ -export function installationFeedback(stats = {}, now = Date.now()) { - const reasons = { - rate_limited: 'Overpass rate-limited', - timeout: 'Overpass timed out', - query_failed: 'Overpass could not complete the query', - tiles_unavailable: 'Map tiles temporarily unavailable', - names_unavailable: 'Mapped names temporarily unavailable', - }; - const reason = - reasons[stats.failureReason] || 'Overpass temporarily unavailable'; +// Portable node/browser module (the HUD summary in loadingFeedback.js reads +// it too), so it marks its copy with a local msg() instead of importing i18n; +// installations/controls.js translates describeInstallationFeedback(). +const msg = (text) => text; + +const REASONS = Object.freeze({ + rate_limited: msg('Overpass rate-limited'), + timeout: msg('Overpass timed out'), + query_failed: msg('Overpass could not complete the query'), + tiles_unavailable: msg('Map tiles temporarily unavailable'), + names_unavailable: msg('Mapped names temporarily unavailable'), +}); +const FALLBACK_REASON = msg('Overpass temporarily unavailable'); + +/** + * Mapped-site availability as a message to render, without claiming an + * unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is + * English (`{reason}` / `{seconds}` placeholders, `reason` English too), or + * `{ count, km }` for a found-sites count (`km` is null for the view). + * @param {object} [stats] + * @param {number} [now] + * @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}} + */ +export function describeInstallationFeedback(stats = {}, now = Date.now()) { + const reason = REASONS[stats.failureReason] || FALLBACK_REASON; if (stats.loading) - return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…'; + return { + text: stats.retrying + ? msg('Retrying mapped sites…') + : msg('Fetching mapped sites…'), + }; if (stats.retryAt > 0) { const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000)); - return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`; + return seconds + ? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds } + : { text: msg('{reason} — retry pending'), reason }; } - if (stats.status === 'unavailable') return reason; + if (stats.status === 'unavailable') return { text: reason }; if (stats.status === 'zoom-in') - return 'Zoom in to search mapped installations'; - if (stats.stale) return 'Showing cached mapped sites'; - if (stats.status === 'idle') return 'Mapped sites not loaded'; + return { text: msg('Zoom in to search mapped installations') }; + if (stats.stale) return { text: msg('Showing cached mapped sites') }; + if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') }; // With a count, say what was found and where; an empty area is not "loaded". if (Number.isFinite(stats.count)) { const km = @@ -27,9 +47,21 @@ export function installationFeedback(stats = {}, now = Date.now()) { Number.isFinite(stats.coverage.radiusM) ? Math.round(stats.coverage.radiusM / 1000) : null; - const where = km ? ` within ${km} km of the contact` : ' in view'; - if (stats.count === 0) return `No mapped sites${where}`; - return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`; + return { count: stats.count, km: km || null }; } - return 'Mapped sites loaded'; + return { text: msg('Mapped sites loaded') }; +} + +/** Explain mapped-site availability in English. */ +export function installationFeedback(stats = {}, now = Date.now()) { + const feedback = describeInstallationFeedback(stats, now); + if (feedback.text !== undefined) + return feedback.text + .replace('{reason}', feedback.reason) + .replace('{seconds}', feedback.seconds); + const where = feedback.km + ? ` within ${feedback.km} km of the contact` + : ' in view'; + if (feedback.count === 0) return `No mapped sites${where}`; + return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`; } diff --git a/src/data/installationNames.js b/src/data/installationNames.js new file mode 100644 index 000000000..8b3597f1e --- /dev/null +++ b/src/data/installationNames.js @@ -0,0 +1,32 @@ +/** + * The English names the data gives a military installation that has no + * mapped name. They stay English in data — the server provider, the AI tools + * and the voice context read the records — and the display translates them + * (installationTitle in src/layers/installations/classLabel.js and + * formatAwarenessLabel in src/data/militaryAwarenessEngine.js). + * + * The server, tools and source modules load this file, so it marks the names + * with a local msg() instead of importing i18n. + */ +const msg = (text) => text; + +/** Name of an unnamed feature, by installation class. */ +export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({ + airfield: msg('Military airfield'), + naval_base: msg('Naval base'), + range: msg('Firing range'), + military_land: msg('Military land'), +}); + +/** Name of an unnamed feature without a usable class. */ +export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation'); + +/** Name of a military polygon the names pack has not named. */ +export const UNNAMED_MILITARY_AREA = msg('Military area'); + +/** Every name above, for the display to translate. */ +export const UNNAMED_PLACE_NAMES = Object.freeze([ + ...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS), + UNNAMED_INSTALLATION_FALLBACK, + UNNAMED_MILITARY_AREA, +]); diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index a3e99afe1..0f889d53f 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,6 +1,9 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ import { t } from '../i18n/index.js'; +import { UNNAMED_PLACE_NAMES } from './installationNames.js'; + +const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); export const AWARENESS_RADIUS_M = 250000; export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000; @@ -49,7 +52,11 @@ export function formatAwarenessDistance(meters) { : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`; } -/** Return a stable visible label without leaking missing values into UI text. */ +/** + * Return a stable visible label without leaking missing values into UI text. + * The English name the data gives an unnamed installation ("Military area") + * is shown in the UI language; mapped names and callsigns show as they are. + */ export function formatAwarenessLabel(value) { if (value && typeof value === 'object') { for (const candidate of [ @@ -65,7 +72,11 @@ export function formatAwarenessLabel(value) { } return '—'; } - if (typeof value === 'string') return value.trim() || '—'; + if (typeof value === 'string') { + const text = value.trim(); + if (!text) return '—'; + return UNNAMED_NAMES.has(text) ? t(text) : text; + } if (Number.isFinite(value)) return String(value); return '—'; } diff --git a/src/data/militaryInstallationData.js b/src/data/militaryInstallationData.js index 9254b2b3b..25092014d 100644 --- a/src/data/militaryInstallationData.js +++ b/src/data/militaryInstallationData.js @@ -1,3 +1,8 @@ +import { + UNNAMED_INSTALLATION_FALLBACK, + UNNAMED_INSTALLATION_LABEL_BY_CLASS, +} from './installationNames.js'; + /** * Normalize the deliberately small, allow-listed installation context returned * by `/api/military-installations`. These are mapped features, not assertions @@ -22,12 +27,17 @@ const CLASS_BY_MILITARY_TAG = { * nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where * attribution and the details panel read it. */ -const LABEL_BY_CLASS = { - airfield: 'Military airfield', - naval_base: 'Naval base', - range: 'Firing range', - military_land: 'Military land', -}; +// The labels stay English in data (the server provider and AI tools read the +// records too); the map card and Contacts translate them (installationNames.js). +const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS; +const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK; + +/** Every name humanizeInstallationClass gives a record of a class + * CLASS_BY_MILITARY_TAG can produce, for the display to translate. */ +export const UNNAMED_INSTALLATION_NAMES = Object.freeze([ + ...Object.values(LABEL_BY_CLASS), + UNNAMED_FALLBACK, +]); const MAX_FOOTPRINT_POINTS = 400; @@ -44,7 +54,7 @@ export function humanizeInstallationClass(klass) { const words = key.replaceAll('_', ' ').trim(); return words ? `${words[0].toUpperCase()}${words.slice(1)}` - : 'Mapped installation'; + : UNNAMED_FALLBACK; } function finiteLatitude(value) { diff --git a/src/data/pointerOwnerNames.js b/src/data/pointerOwnerNames.js new file mode 100644 index 000000000..53a992b10 --- /dev/null +++ b/src/data/pointerOwnerNames.js @@ -0,0 +1,30 @@ +/** + * The tool name to show for a pointer owner. + * + * Pointer-owner ids (src/data/inputOwnership.js) are stable internal + * identifiers — 'draw', 'recent-imagery-box' — and never belong on screen. + * This maps each to the English name of the tool, which the caller translates + * with translateKnown() where it is shown. Pure and stateless, so any package + * can carry it without a second copy of the pointer claim. + */ + +// English marker for the i18n extractor; this module imports no i18n. +const msg = (text) => text; + +const POINTER_OWNER_NAMES = Object.freeze({ + draw: msg('Draw'), + directions: msg('Directions'), + 'recent-imagery-box': msg('Recent imagery'), +}); + +/** + * English display name of a pointer owner. An owner without a registered name + * falls back to its id. + * @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it. + * @returns {string} + */ +export function pointerOwnerName(owner) { + const id = typeof owner === 'string' ? owner.trim() : ''; + if (!id) return ''; + return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id; +} diff --git a/src/data/satellitesTrackedRefresh.test.mjs b/src/data/satellitesTrackedRefresh.test.mjs index 09c1bc515..cf70b373c 100644 --- a/src/data/satellitesTrackedRefresh.test.mjs +++ b/src/data/satellitesTrackedRefresh.test.mjs @@ -19,8 +19,10 @@ import { import satellitesLayer from './satellites.js'; import { createTrackedOverlayEntry } from './trackedReadout.js'; -const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; -const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; +const L1 = + '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; +const L2 = + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; test('partial and dense catalogs do not make an early false missing decision', async () => { _setSatelliteTrackingRefreshOutcomeForTest({ @@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a assert.match(partial.reason, /partial CelesTrak catalog/); let releaseDense; - const densePromise = new Promise((resolve) => { releaseDense = resolve; }); + const densePromise = new Promise((resolve) => { + releaseDense = resolve; + }); _setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'dense', densePromise, }); let settled = false; - const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654) - .then((result) => { settled = true; return result; }); + const resolution = satellitesLayer + .resolveTrackingRestoreTarget(987654) + .then((result) => { + settled = true; + return result; + }); await Promise.resolve(); assert.equal(settled, false, 'dense absence must wait for the dense catalog'); releaseDense({ status: 'ready' }); assert.equal((await resolution).status, 'missing'); - _setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' }); + _setSatelliteTrackingRefreshOutcomeForTest({ + status: 'accepted', + catalog: 'core', + }); }); test('satellite first update aborts every parallel catalog request', async () => { const realFetch = globalThis.fetch; const observedSignals = []; - globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { - observedSignals.push(options.signal); - options.signal?.addEventListener('abort', () => { - reject(new DOMException('aborted', 'AbortError')); - }, { once: true }); - }); + globalThis.fetch = (_url, options = {}) => + new Promise((_resolve, reject) => { + observedSignals.push(options.signal); + options.signal?.addEventListener( + 'abort', + () => { + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }); try { const controller = new AbortController(); const work = satellitesLayer.update({}, { signal: controller.signal }); @@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () => assert.ok(observedSignals.length > 1); controller.abort(); await assert.rejects(work, { name: 'AbortError' }); - assert.equal(observedSignals.every((signal) => signal?.aborted), true); + assert.equal( + observedSignals.every((signal) => signal?.aborted), + true, + ); } finally { globalThis.fetch = realFetch; } @@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli point, }); try { - assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 })); + assert.doesNotThrow(() => + satellitesLayer.setParams({ selectedSatTrackingId: 25544 }), + ); assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544); assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544'); @@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli ); assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999); assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true); - assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); + assert.equal( + _pendingSatelliteTrackingRestoreForTest(), + null, + 'new user selection cancels stale restore', + ); _applyPendingSatelliteTrackingRestoreForTest(); assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544); @@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli ); assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544); satellitesLayer.stopTracking({ origin: 'user' }); - assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); + assert.equal( + _pendingSatelliteTrackingRestoreForTest(), + null, + 'explicit clear cancels stale restore', + ); _setSatelliteLabelLifecycleStateForTest({ viewer, satrec: twoline2satrec(L1, L2), @@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () => viewer, now: () => nowMs, neighbours: [ - { noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed - { noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed + { noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed + { noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed { noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away ], }); @@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () => const details = entity.gevLabelModel.details; // Class leads the detail block; the altitude line follows it, and the // consolidated companions stay last. - assert.equal(details[0], 'STATION · ISS', 'the class line names what this is'); + assert.equal( + details[0], + 'STATION · ISS', + 'the class line names what this is', + ); assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged'); - assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1', - 'companions are consolidated, named, and counted on the tracked card'); + assert.equal( + details[2], + 'DOCKED · PROGRESS-MS 34 · +1', + 'companions are consolidated, named, and counted on the tracked card', + ); // Scoping is encoded in that string: THREE neighbours were seeded but only two // are counted (the named one plus "+1"). The satellite 50 km away is not part @@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra camera: null, scene: { frameState: { frameNumber: 1 } }, }; - const epochs = [ - Date.UTC(2008, 8, 20, 12, 30), - Date.UTC(2008, 8, 20, 13, 0), - ]; + const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)]; let epochIndex = 0; _setTrackedSatelliteRefreshStateForTest({ noradId: 25544, @@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra _runSatellitePreRenderForTest(); assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)'); assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS'); - assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544'); + // formatQuantity keeps the number and its unit together with a no-break space. + assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544'); epochIndex = 1; viewer.scene.frameState.frameNumber = 2; _runSatellitePreRenderForTest(); - assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544'); - assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS', - 'the class line survives an altitude-only republish'); + assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544'); + assert.equal( + entity.gevLabelModel.details[0], + 'STATION · ISS', + 'the class line survives an altitude-only republish', + ); }); test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => { @@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS); const ambient = ambientPublication[2][0]; assert.equal(ambient.title, 'ISS'); - assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache'); - assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic'); + assert.equal( + ambient.position(), + point.position, + 'ISS getter reads the existing point cache', + ); + assert.equal( + point.label, + undefined, + 'ISS point primitive carries no label graphic', + ); const trackedEntity = _trackIssForTest(); assert.ok(trackedEntity); - assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity'); + assert.equal( + viewer.entities.values.length, + 1, + 'runtime guard requires a real tracked entity', + ); assert.equal(trackedEntity.label, undefined); - assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing'); + assert.ok( + trackedEntity.point, + 'tracked dot remains on the entity for camera framing', + ); const trackedEntry = createTrackedOverlayEntry(trackedEntity); assert.ok(trackedEntry); assert.equal(trackedEntry.id, 'satellites:25544'); assert.equal(trackedEntry.protected, true); assert.equal(trackedEntry.paintLane, 'tracked'); assert.equal( - calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length, + calls.filter( + ([type, sourceId]) => + type === 'entries' && sourceId === 'satellites-iss', + ).length, 2, 'tracking may refresh before ownership changes but must not publish a second ISS entry', ); - assert.deepEqual(calls.slice(-2), [ - ['clear', 'satellites-iss'], - ['visible', 'satellites-iss', false], - ], 'tracked ISS suppresses ambient text so the tracked card is the only text surface'); + assert.deepEqual( + calls.slice(-2), + [ + ['clear', 'satellites-iss'], + ['visible', 'satellites-iss', false], + ], + 'tracked ISS suppresses ambient text so the tracked card is the only text surface', + ); _clearSatelliteLabelLifecycleForTest(); cleaned = true; assert.equal(viewer.entities.values.length, 0); assert.equal( - calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length, + calls.filter( + ([type, sourceId]) => + type === 'entries' && sourceId === 'satellites-iss', + ).length, 3, 'untracking republishes exactly one persistent ISS ambient entry before teardown', ); diff --git a/src/data/trackedCardLocale.test.mjs b/src/data/trackedCardLocale.test.mjs new file mode 100644 index 000000000..e5f72c3f9 --- /dev/null +++ b/src/data/trackedCardLocale.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { twoline2satrec } from 'satellite.js'; +import { + _runSatellitePreRenderForTest, + _setTrackedSatelliteRefreshStateForTest, + _trackedFrameCartesianForTest, +} from './satellites.js'; +import { setLocale } from '../i18n/index.js'; + +const L1 = + '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; +const L2 = + '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; + +function run(locale) { + setLocale(locale); + const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } }; + const point = { position: new Cesium.Cartesian3() }; + const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } }; + let nowMs = Date.UTC(2008, 8, 20, 12, 30); + const near = (metres) => ({ + get position() { + const tracked = _trackedFrameCartesianForTest(); + return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z); + }, + }); + _setTrackedSatelliteRefreshStateForTest({ + noradId: 25544, + name: 'ISS (ZARYA)', + satrec: twoline2satrec(L1, L2), + entity, + point, + viewer, + now: () => nowMs, + neighbours: [ + { noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, + { noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, + ], + }); + _runSatellitePreRenderForTest(); + nowMs += 2000; + viewer.scene.frameState.frameNumber = 2; + _runSatellitePreRenderForTest(); + const details = entity.gevLabelModel.details; + setLocale('en'); + return details; +} + +test('K-32 es: tracked ISS card', () => { + const details = run('es'); + console.log('es details:', JSON.stringify(details)); + assert.equal(details[0], 'ESTACIÓN · ISS'); + assert.doesNotMatch( + details[2], + /^DOCKED/, + 'companions line should be localized', + ); +}); + +test('K-32 ru: tracked ISS card', () => { + const details = run('ru'); + console.log('ru details:', JSON.stringify(details)); + assert.equal(details[0], 'СТАНЦИЯ · ISS'); + assert.match(details[1], /км/, 'ru glossary: km → км'); + assert.doesNotMatch( + details[2], + /^DOCKED/, + 'companions line should be localized', + ); +}); diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 079cdfbb3..c3936bcfe 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -15,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900; export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT; /** Existing ambient vessel-card distance fade reaches zero at 5000 km. */ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000; +/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */ +export const VESSEL_CARD_FADE_START_RATIO = 0.7; /** * AIS type family → chevron hue + card accent. Single source of truth for @@ -187,7 +189,7 @@ export function applyVesselOverlayPolicy( verticalOnly: !selected, viewportMargin: 4, maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance, - distanceFadeStartRatio: 0.7, + distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO, edgeFade: 'keyhole', horizonCull: true, terrainOcclusion: false, diff --git a/src/data/vesselTypeCardLabel.test.mjs b/src/data/vesselTypeCardLabel.test.mjs new file mode 100644 index 000000000..c9b108689 --- /dev/null +++ b/src/data/vesselTypeCardLabel.test.mjs @@ -0,0 +1,96 @@ +// K-12: a translated vessel type is never cut mid-word on the card. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js'; +import { displayVesselType } from './vesselLabels.js'; +import { setLocale } from '../i18n/index.js'; + +const POS = new Cesium.Cartesian3(1, 2, 3); +const rec = (type) => ({ + name: 'X', + mmsi: '1', + type, + destination: '', + speed: 5, + course: 10, + heading: 10, + lastPositionUtc: '', + missedRefreshes: 0, + position: POS, + billboard: null, +}); +// Every AIS ShipType code with its own display token. +const CODES = [ + '30', + '31', + '33', + '34', + '35', + '36', + '37', + '50', + '51', + '52', + '53', + '54', + '55', + '58', + '40', + '60', + '70', + '80', + '90', +]; + +// The card label is acceptable when it is the whole translation, or when it +// was shortened at a word boundary and marked with an ellipsis. +function assertNotCutMidWord(label, full, where) { + if (label === full) return; + const head = label.replace(/(…|\.\.\.)$/, ''); + assert.notEqual( + head, + label, + `${where}: "${label}" cut from "${full}" without an ellipsis`, + ); + assert.ok( + full.startsWith(head), + `${where}: "${label}" is not a prefix of "${full}"`, + ); + assert.match( + full.slice(head.length), + /^[\s-]/, + `${where}: "${label}" cuts "${full}" mid-word`, + ); +} + +for (const locale of ['es', 'ru']) { + test(`${locale}: vessel type on cards is never cut mid-word`, () => { + setLocale(locale); + try { + for (const code of CODES) { + const full = displayVesselType(code).toUpperCase(); + const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0]; + const selected = buildSelectedVesselCard(rec(code)).details[0].split( + ' · ', + )[0]; + assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`); + assertNotCutMidWord(selected, full, `${locale} ${code} selected`); + } + } finally { + setLocale('en'); + } + }); +} + +test('en: free feed text is still bounded to the card width', () => { + setLocale('en'); + assert.equal( + buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0], + 'CRUDE OIL TANK', + ); + assert.equal( + buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0], + 'ANTI-POLLUTION', + ); +}); diff --git a/src/director/documentFields.js b/src/director/documentFields.js index 681cc6482..67dc6c3da 100644 --- a/src/director/documentFields.js +++ b/src/director/documentFields.js @@ -15,6 +15,7 @@ export class SceneDocumentError extends Error { super(`${path}: ${reason}`); this.name = 'SceneDocumentError'; this.path = path; + this.reason = reason; } } export const fail = (path, reason) => { diff --git a/src/hud.js b/src/hud.js index c827bb574..7c659d1c4 100644 --- a/src/hud.js +++ b/src/hud.js @@ -38,6 +38,7 @@ import { import { formatNumber, msg, t } from './i18n/index.js'; import { unitLabel } from './ui/unitLabels.js'; import { localizeLayerName } from './ui/layerPanel.js'; +import { localizePlaceName } from './ui/placeNames.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -157,7 +158,7 @@ const LOCALIZED_LOCALITY = Object.freeze({ near: ({ poi, city, distKm }) => t('NEAR {poi} ({city}) {distance}', { poi, - city, + city: localizePlaceName(city), distance: hudQuantity(Math.round(distKm), 'km'), }), sector: ({ latDeg, lonDeg }) => diff --git a/src/i18n/empty-translation.test.mjs b/src/i18n/empty-translation.test.mjs new file mode 100644 index 000000000..f80b07809 --- /dev/null +++ b/src/i18n/empty-translation.test.mjs @@ -0,0 +1,62 @@ +// K-21: an empty or blank translation must not count as done. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + checkCatalog, + extractMessages, + readCatalog, +} from '../../scripts/i18n.mjs'; +import { getCatalog, setLocale, t, tn, translateKnown } from './index.js'; + +const messages = extractMessages(); +const EMPTY = 'DISCONNECT'; +const BLANK = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; + +for (const locale of ['es', 'ru']) { + test(`${locale}: checkCatalog rejects an empty or blank translation`, () => { + const catalog = { ...readCatalog(locale), [EMPTY]: '', [BLANK]: ' ' }; + const { errors } = checkCatalog(locale, catalog, messages); + assert.ok( + errors.some((e) => e.includes(`"${EMPTY}"`)), + 'empty string passes the check', + ); + assert.ok( + errors.some((e) => e.includes(`"${BLANK}"`)), + 'blank string passes the check', + ); + }); +} + +test('t() and translateKnown() fall back to English for an empty entry', () => { + const es = getCatalog('es'); + const saved = es[EMPTY]; + es[EMPTY] = ''; + setLocale('es'); + try { + assert.equal(t(EMPTY), EMPTY); + assert.equal(translateKnown(EMPTY), EMPTY); + } finally { + es[EMPTY] = saved; + setLocale('en'); + } +}); + +test('tn() skips an empty plural form', () => { + const es = getCatalog('es'); + const KEY = '{count} INPUTS UNKNOWN'; + const saved = es[KEY]; + setLocale('es'); + try { + es[KEY] = { ...saved, one: ' ' }; + assert.equal( + tn(1, '{count} INPUT UNKNOWN', KEY), + saved.other.replace('{count}', '1'), + ); + es[KEY] = { one: '', many: '', other: '' }; + assert.equal(tn(1, '{count} INPUT UNKNOWN', KEY), '1 INPUT UNKNOWN'); + assert.equal(tn(3, '{count} INPUT UNKNOWN', KEY), '3 INPUTS UNKNOWN'); + } finally { + es[KEY] = saved; + setLocale('en'); + } +}); diff --git a/src/i18n/index.js b/src/i18n/index.js index 614e43401..21314125a 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -9,7 +9,7 @@ // // English needs no catalog. Every other locale is one flat JSON file, // locales/.json, keyed by the English text (for tn, by the English -// plural form). A missing entry falls back to English. +// plural form). A missing or empty entry falls back to English. // `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md. import { DEFAULT_LOCALE, @@ -66,10 +66,14 @@ function interpolate(pattern, values) { ); } +/** A catalog string with something to show; an empty one counts as missing. */ +const filled = (value) => typeof value === 'string' && value.trim() !== ''; + function lookup(text) { const catalog = CATALOGS[currentLocale]; if (!catalog) return undefined; - if (Object.hasOwn(catalog, text)) return catalog[text]; + const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; + if (filled(entry) || (entry && typeof entry === 'object')) return entry; warnMissing(text); return undefined; } @@ -101,8 +105,9 @@ export function tn(count, one, other, values) { const entry = lookup(other); const category = pluralRules(currentLocale).select(count); let pattern; - if (entry && typeof entry === 'object') { - pattern = entry[category] ?? entry.other ?? other; + const forms = entry && typeof entry === 'object' ? entry : null; + if (forms && (filled(forms[category]) || filled(forms.other))) { + pattern = filled(forms[category]) ? forms[category] : forms.other; } else { pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other; } @@ -117,8 +122,8 @@ export function tn(count, one, other, values) { export function translateKnown(text) { const catalog = CATALOGS[currentLocale]; if (!catalog || typeof text !== 'string') return text; - const entry = catalog[text]; - return typeof entry === 'string' ? entry : text; + const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; + return filled(entry) ? entry : text; } /** Mark English text that is stored in data and translated later by t(). */ @@ -247,10 +252,43 @@ export function applyDocumentTranslations(doc) { return applied; } +const unsavedWorkChecks = new Set(); + +/** + * Name work that switching language would lose: the switch reloads the page, + * and the language switch asks first while any check reports something. + * @param {() => string|null|undefined} check Returns a msg() label (e.g. + * msg('unsaved CCTV calibration')) while there is such work, else nothing. + * @returns {() => void} Removes the check. + */ +export function registerUnsavedWork(check) { + if (typeof check !== 'function') return () => {}; + unsavedWorkChecks.add(check); + return () => unsavedWorkChecks.delete(check); +} + +/** + * What a reload would lose right now, translated, without repeats. + * @returns {string[]} + */ +export function describeUnsavedWork() { + const labels = new Set(); + for (const check of unsavedWorkChecks) { + try { + const label = check(); + if (typeof label === 'string' && label) labels.add(t(label)); + } catch { + // A failing check must not block the switch. + } + } + return [...labels]; +} + /** * Persist a locale choice, then reload preserving window.location.hash exactly - * and stripping ?lang from the search string, so the override is one-shot. - * Where storage is blocked, ?lang carries the choice instead. + * and stripping ?lang from the search string, so the stored choice applies. + * Where storage is blocked, ?lang carries the choice instead (share links + * drop it: ShareLinkManager.copyLink). * @returns {boolean} true when a reload was triggered. */ export function persistLocaleAndReload( diff --git a/src/i18n/kartaviewMessages.test.mjs b/src/i18n/kartaviewMessages.test.mjs new file mode 100644 index 000000000..78e062a2f --- /dev/null +++ b/src/i18n/kartaviewMessages.test.mjs @@ -0,0 +1,62 @@ +// K-194: the KartaView provider's failure and state texts are English data +// (msg() in kartaview/{policy,source,viewer,index}.js) that the Street Level +// panel and the chip tooltip show through translateKnown, so every one needs +// an entry in each language's catalog. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, translateKnown } from './index.js'; + +const MESSAGES = [ + // kartaview/policy.js + 'KartaView is rate-limiting requests — they resume on their own', + 'KartaView’s photos here are archived and cannot be shown', + 'KartaView did not filter this area in time — move the map to ask again', + 'None of KartaView’s photos here pass the imagery filter', + // kartaview/source.js + 'KartaView did not answer in time — try again', + 'KartaView could not be reached — check the network', + 'KartaView sent an answer that could not be read', + 'KartaView is rate-limiting requests', + 'KartaView is rate-limiting requests — try again in a moment', + 'KartaView sent too much data', + 'KartaView has no such photo', + // kartaview/viewer.js, index.js + 'The KartaView photo could not be loaded', + 'KartaView has no position for this photo', +]; + +for (const locale of ['es', 'ru']) { + test(`KartaView provider messages are translated in ${locale}`, () => { + setLocale(locale); + try { + const untranslated = MESSAGES.filter((m) => translateKnown(m) === m); + assert.deepEqual(untranslated, []); + } finally { + setLocale('en'); + } + }); +} + +// kartaview/source.js refusals with a number in them: English data, shown +// through placeholder entries by the Street Level panel and the layer panel. +test('KartaView HTTP and refusal codes keep their number in every language', async () => { + const { translateStreetLevelError } = + await import('../ui/streetLevelPresentation.js'); + const http = 'KartaView answered HTTP 503'; + const refused = 'KartaView refused the request (code 610)'; + assert.equal(translateStreetLevelError(http), http); + assert.equal(translateStreetLevelError(refused), refused); + for (const locale of ['es', 'ru']) { + setLocale(locale); + try { + const shownHttp = translateStreetLevelError(http); + const shownRefused = translateStreetLevelError(refused); + assert.notEqual(shownHttp, http); + assert.match(shownHttp, /503/); + assert.notEqual(shownRefused, refused); + assert.match(shownRefused, /610/); + } finally { + setLocale('en'); + } + } +}); diff --git a/src/i18n/layerStatusText.test.mjs b/src/i18n/layerStatusText.test.mjs new file mode 100644 index 000000000..8b1d37f39 --- /dev/null +++ b/src/i18n/layerStatusText.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { setLocale, translateKnown } from './index.js'; + +// Status and error texts the layers write straight into their state +// (loadingLabel / _error / street.error) as English data; the layer panel and +// the Street Level panel show them through translateKnown, so each needs a +// catalog entry in every language (K-36). +const SITES = [ + ['../layers/streetLevel/index.js', 'loading coverage...'], + ['../layers/firms/ingestion.js', 'live feed unavailable'], + ['../layers/bikeshare/viewport.js', 'GBFS fetch error'], + ['../layers/bikeshare/ingestion.js', 'GBFS status update failed'], + [ + '../layers/streetLevel/viewerHost.js', + 'Open the Street Level panel to view imagery', + ], + ['../layers/streetLevel/viewerHost.js', 'Image could not be opened'], + ['../layers/satellites/ingestion.js', 'CelesTrak unreachable'], + [ + '../layers/streetLevel/providers/mapillary/policy.js', + 'Coverage requests are being rate-limited — they resume on their own', + ], + ['../layers/alpr/index.js', 'Camera source temporarily unavailable'], + ['../layers/perimeters/index.js', 'Perimeter source unavailable'], + ['../layers/recentImagery/index.js', 'Imagery catalog unavailable'], + ['../layers/installations/ingestion.js', 'Installation context unavailable'], + ['../layers/earthquakes/index.js', 'Earthquake source unavailable'], + ['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'], + ['../layers/vessels/ingestion.js', 'AIS live load failed'], + ['../layers/vessels/lifecycle.js', 'AIS live load failed'], + ['../layers/wind/index.js', 'Wind unavailable'], + ['../layers/wind/source.js', 'Wind request timed out'], + ['../layers/wind/source.js', 'Malformed wind manifest'], + ['../layers/wind/source.js', 'Malformed wind grid'], + ['../layers/wind/source.js', 'Temperature field unavailable'], + ['../layers/wind/source.js', 'Mean sea level pressure field unavailable'], +]; + +// A fallback that reaches the panel must be marked, or `npm run i18n` cannot +// see it and the catalog entry would be pruned as unused. +for (const [file, text] of SITES) { + test(`"${text}" in ${file} is marked`, () => { + const source = readFileSync(new URL(file, import.meta.url), 'utf8'); + assert.ok( + new RegExp( + String.raw`\b(?:msg|t)\(\s*'${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`, + ).test(source), + `${text} is not marked in ${file}`, + ); + }); +} + +for (const locale of ['es', 'ru']) { + for (const [file, text] of SITES) { + test(`${locale}: "${text}" from ${file} is translated`, () => { + const source = readFileSync(new URL(file, import.meta.url), 'utf8'); + assert.ok(source.includes(`'${text}'`), `literal still in ${file}`); + setLocale(locale); + try { + assert.notEqual(translateKnown(text), text); + } finally { + setLocale('en'); + } + }); + } +} diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e55dd8e72..5fa201f0b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -10,7 +10,7 @@ "LOAD FAILED": "CARGA FALLIDA", "LOAD CANCELLED": "CARGA CANCELADA", "syncing road network": "sincronizando la red vial", - "retrying in {seconds}s": "reintentando en {seconds} s", + "retrying in {seconds}s": "reintento en {seconds} s", "retry pending": "reintento pendiente", "Clear selected data layers": "Limpiar las capas de datos seleccionadas", "Turn off all selected data layers": "Desactivar todas las capas de datos seleccionadas", @@ -812,7 +812,7 @@ "Check microphone permission and network access, then try again.": "Revisa el permiso del micrófono y el acceso a la red, y vuelve a intentarlo.", "AI AGENT": "AGENTE DE IA", "VOICE CONTROL": "CONTROL DE VOZ", - "{tier} applies next session": "{tier} se aplica en la próxima sesión", + "{tier} applies next session": "{tier} desde la próxima sesión", "Voice model tier — applies next session": "Nivel del modelo de voz — se aplica en la próxima sesión", "Estimated session cost": "Costo estimado de la sesión", "Voice control — activate to toggle voice; hold Space to speak": "Control de voz — púlsalo para activar o desactivar la voz; mantén pulsado Space para hablar", @@ -989,7 +989,7 @@ "Coverage colours": "Colores de cobertura", "That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.", "Could not place the shape: {error}": "No se pudo colocar la forma: {error}", - "{owner} is using the pointer — close it first.": "{owner} está usando el puntero — ciérralo primero.", + "{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.", "{label} · Detailed outline unavailable": "{label} · Contorno detallado no disponible", "Witness source": "Fuente de testigo", "{provider} · EMBEDDED SOURCE": "{provider} · FUENTE INSERTADA", @@ -1758,7 +1758,7 @@ "Mark {targets} +{more}": "Marcar {targets} +{more}", "Mark {targets}": "Marcar: {targets}", "Fly to {place}": "Volar a {place}", - "Nearest aircraft to {place}": "Aeronaves más cercanas a {place}", + "Nearest aircraft to {place}": "Aeronave más cercana a {place}", "Read the view": "Consultar la vista", "Check view state": "Comprobar el estado de la vista", "Frame {target}": "Encuadrar {target}", @@ -2208,5 +2208,558 @@ "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra en el globo tus propios archivos GeoJSON, KML, KMZ o GPX (o suéltalos sobre él). Los archivos no salen de este dispositivo; las capas pueden aparecer en capturas del mapa compartidas con la voz.", "Import": "Importar", "Choose GeoJSON, KML, KMZ or GPX files": "Elige archivos GeoJSON, KML, KMZ o GPX", - "Imported files": "Archivos importados" + "Imported files": "Archivos importados", + "DOCKED · {name}": "ACOPLE · {name}", + "DOCKED · {name} · +{extra}": "ACOPLE · {name} · +{extra}", + "loading coverage...": "cargando cobertura...", + "live feed unavailable": "fuente en vivo no disponible", + "GBFS fetch error": "error al obtener datos GBFS", + "GBFS status update failed": "falló la actualización del estado GBFS", + "Open the Street Level panel to view imagery": "Abre el panel \"A pie de calle\" para ver las imágenes", + "Image could not be opened": "No se pudo abrir la imagen", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary rechazó MAPILLARY_CLIENT_TOKEN — sustitúyelo en Ajustes de proveedores", + "Coverage requests are being rate-limited — they resume on their own": "Las peticiones de cobertura están limitadas por frecuencia — se reanudan solas", + "No street-level imagery within {radius} m of that point": "No hay imágenes a pie de calle a menos de {radius} m de ese punto", + "No street-level imagery within {radius} m of the view centre": "No hay imágenes a pie de calle a menos de {radius} m del centro de la vista", + "Aim the middle of the view at a street to look for imagery there": "Apunta el centro de la vista a una calle para buscar imágenes allí", + "Street View has no imagery for this place": "Street View no tiene imágenes de este lugar", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada", + "The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google", + "Weather request timed out": "La petición del clima agotó el tiempo de espera", + "Malformed weather manifest": "Manifiesto del clima mal formado", + "Weather HTTP {status}": "Clima: HTTP {status}", + "Weather image HTTP {status}": "Imagen del clima: HTTP {status}", + "Weather prefetch unavailable": "Precarga del clima no disponible", + "Infrared decode failed": "Falló la decodificación del infrarrojo", + "rate limited": "límite de peticiones superado", + "routing service is rate limited": "el servicio de rutas superó el límite de peticiones", + "route leg too long": "tramo de la ruta demasiado largo", + "route too long": "ruta demasiado larga", + "Routing is rate limited — try again in a moment": "Se alcanzó el límite de peticiones de rutas — inténtalo de nuevo en un momento", + "{error} — try again in a moment": "{error} — inténtalo de nuevo en un momento", + "Routing unavailable (HTTP {status})": "Rutas no disponibles (HTTP {status})", + "CelesTrak unreachable": "CelesTrak inaccesible", + "KartaView is rate-limiting requests — they resume on their own": "KartaView está limitando las peticiones — se reanudan solas", + "KartaView’s photos here are archived and cannot be shown": "Las fotos de KartaView de aquí están archivadas y no se pueden mostrar", + "KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo", + "None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes", + "KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo", + "KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red", + "KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer", + "KartaView is rate-limiting requests": "KartaView está limitando las peticiones", + "KartaView is rate-limiting requests — try again in a moment": "KartaView está limitando las peticiones — inténtalo de nuevo en un momento", + "KartaView sent too much data": "KartaView envió demasiados datos", + "KartaView has no such photo": "KartaView no tiene esa foto", + "The KartaView photo could not be loaded": "No se pudo cargar la foto de KartaView", + "KartaView has no position for this photo": "KartaView no tiene la posición de esta foto", + "Map tiles temporarily unavailable": "Teselas del mapa temporalmente no disponibles", + "Mapped names temporarily unavailable": "Nombres mapeados temporalmente no disponibles", + "No mapped sites in view": "No hay sitios mapeados a la vista", + "No mapped sites within {km} km of the contact": "No hay sitios mapeados a menos de {km} km del contacto", + "{count} mapped sites in view": { + "one": "{count} sitio mapeado a la vista", + "many": "{count} de sitios mapeados a la vista", + "other": "{count} sitios mapeados a la vista" + }, + "{count} mapped sites within {km} km of the contact": { + "one": "{count} sitio mapeado a menos de {km} km del contacto", + "many": "{count} de sitios mapeados a menos de {km} km del contacto", + "other": "{count} sitios mapeados a menos de {km} km del contacto" + }, + "Too many mapped sites in view to list them all": "Hay demasiados sitios mapeados a la vista para listarlos todos", + "Installation context unavailable": "Contexto de instalaciones no disponible", + "Focus vessel {name}, MMSI {mmsi}": "Enfocar el buque {name}, MMSI {mmsi}", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Abrir el informe inicial del USGS sobre el deslizamiento del 26 de agosto de 2026 en Nepal", + "YouTube source": "Fuente de YouTube", + "Facebook source": "Fuente de Facebook", + "Military airfield": "Aeródromo militar", + "Naval base": "Base naval", + "Firing range": "Campo de tiro", + "Military land": "Terreno militar", + "Mapped installation": "Instalación mapeada", + "Military area": "Zona militar", + "SIMULATED — {reason}": "SIMULADO — {reason}", + "TomTom key unavailable": "Clave de TomTom no disponible", + "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", + "TomTom upstream unreachable": "Servidor de TomTom inaccesible", + "TomTom flow unavailable": "Flujo de TomTom no disponible", + "API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "fuente en silencio {seconds} s — sin datos AIS", + "feed silent — no AIS data": "fuente en silencio — sin datos AIS", + "feed down — retrying slowly (attempt {attempt})": "fuente caída — reintentando despacio (intento {attempt})", + "feed down — retrying slowly": "fuente caída — reintentando despacio", + "reconnecting to feed… (attempt {attempt})": "reconectando con la fuente… (intento {attempt})", + "reconnecting to feed…": "reconectando con la fuente…", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY no está configurada", + "live feed unsupported": "fuente en vivo no compatible", + "connecting to feed…": "conectando con la fuente…", + "feed disconnected": "fuente desconectada", + "feed down": "fuente caída", + "feed idle": "fuente inactiva", + "AIS live load failed": "Falló la carga de AIS en vivo", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} acre", + "many": "{acres} de acres", + "other": "{acres} acres" + }, + "{percent}% contained": "contenido al {percent}%", + "containment unknown": "contención desconocida", + "{cause} cause": "causa: {cause}", + "{personnel} personnel": { + "one": "{personnel} efectivo", + "many": "{personnel} de efectivos", + "other": "{personnel} efectivos" + }, + "{county} County": "condado de {county}", + "{cost} to date": "{cost} hasta la fecha", + "discovered {age} ago": "detectado hace {age}", + "updated {age} ago": "actualizado hace {age}", + "part of {complex}": "parte de {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · haz clic en la tarjeta para abrir", + "FIRE · {name}": "INCENDIO · {name}", + "Unnamed incident": "Sin nombre", + "Open {title} on InciWeb": "Abrir {title} en InciWeb", + "Vehicle {vehicle}": "Vehículo {vehicle}", + "Bus": "Autobús", + "Tram": "Tranvía", + "Subway": "Metro", + "Train": "Tren", + "Ferry": "Ferri", + "Transit vehicle": "Vehículo de transporte público", + "No recent fix": "Sin posición reciente", + "Waiting for update": "Esperando actualización", + "Moving": "En movimiento", + "reported {speed} km/h": "velocidad notificada: {speed} km/h", + "reported hdg {heading}°": "rumbo notificado: {heading}°", + "Last report: stopped at stop {stop}": "Último informe: detenido en la parada {stop}", + "Last report: arriving at stop {stop}": "Último informe: llegando a la parada {stop}", + "Last report: next stop {stop}": "Último informe: próxima parada {stop}", + "occupancy|empty": "vacío", + "occupancy|many seats available": "muchos asientos libres", + "occupancy|few seats available": "pocos asientos libres", + "occupancy|standing room only": "solo de pie", + "occupancy|crushed standing room only": "abarrotado, solo de pie", + "occupancy|full": "lleno", + "occupancy|not accepting passengers": "no admite pasajeros", + "occupancy|not boardable": "no se puede subir", + "Shown at street level · depth not in the feed": "Se muestra a nivel de calle · la fuente no indica la profundidad", + "Reported {seconds} s ago": "Notificado hace {seconds} s", + "Reported {minutes} min ago": "Notificado hace {minutes} min", + "{reported} (feed time)": "{reported} (hora de la fuente)", + "shown {seconds} s behind": "se muestra con {seconds} s de retraso", + "Available trail: {minutes} min": "Rastro disponible: {minutes} min", + "Available trail: {minutes} min (limited)": "Rastro disponible: {minutes} min (limitado)", + "UNAVAILABLE · {source} · Roads unavailable": "NO DISPONIBLE · {source} · Vías no disponibles", + "Roads: {source}": "Vías: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "Vías de TomTom no disponibles mientras el servicio de tráfico esté inaccesible", + "Hybrid unavailable while the traffic service is unreachable": "Modo híbrido no disponible mientras el servicio de tráfico esté inaccesible", + "TomTom roads need a TomTom key": "Las vías de TomTom necesitan una clave de TomTom", + "Hybrid needs a TomTom key": "El modo híbrido necesita una clave de TomTom", + "Local surface still loading": "La superficie bajo las vías aún se está cargando", + "Partial coverage": "Cobertura parcial", + "Detailed roads unavailable": "Vías detalladas no disponibles", + "Reduced detail coverage": "Cobertura con menos detalle", + "OpenFreeMap tiles rate-limited": "Teselas de OpenFreeMap con límite de peticiones", + "OpenFreeMap tiles timed out": "Las teselas de OpenFreeMap agotaron el tiempo de espera", + "OpenFreeMap tiles unavailable": "Teselas de OpenFreeMap no disponibles", + "Heard by your receiver": "Captado por tu receptor", + "browser SDR": "SDR del navegador", + "decoder feed": "fuente del decodificador", + "Your RTL-SDR receiver and decoder feed": "Tu receptor RTL-SDR y la fuente del decodificador", + "Your decoder feed": "Tu fuente del decodificador", + "Your RTL-SDR receiver": "Tu receptor RTL-SDR", + "NO CALLSIGN": "SIN INDICATIVO", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · VEL. SUELO {speed} KN · TRK {track}", + "V/S {rate}": "VEL. VERT. {rate}", + "V/S {rate} FPM": "VEL. VERT. {rate} FT/MIN", + "POSITION {age}": "POSICIÓN {age}", + "POSITION {seconds} S AGO": "POSICIÓN HACE {seconds} S", + "{count} MSGS": { + "one": "{count} MENSAJE", + "many": "{count} DE MENSAJES", + "other": "{count} MENSAJES" + }, + "installation class|BARRACKS": "CUARTEL", + "installation class|TRAINING AREA": "ZONA DE INSTRUCCIÓN", + "Seeking up…": "Buscando hacia arriba…", + "Seeking down…": "Buscando hacia abajo…", + "Scanning {mhz} MHz…": "Escaneando {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "Encontrado {mhz} MHz · {snr} dB", + "Broadcaster stream is unavailable or blocked by the browser.": "El flujo de la emisora no está disponible o el navegador lo ha bloqueado.", + "Playback requires a direct click or tap.": "La reproducción requiere un clic o un toque directo.", + "Broadcaster stream could not be started.": "No se pudo iniciar el flujo de la emisora.", + "Directory refresh degraded; showing the previous station catalog.": "La actualización del directorio fue parcial; se muestra el catálogo de emisoras anterior.", + "Radio directory coverage is degraded.": "El directorio de radio funciona con interrupciones.", + "Directory refresh failed; showing the previous station catalog.": "La actualización del directorio falló; se muestra el catálogo de emisoras anterior.", + "Radio directory is temporarily unavailable.": "El directorio de radio no está disponible temporalmente.", + "Decoder feeds: {feeds}": "Fuentes del decodificador: {feeds}", + " · read while Local ADS-B is on": " · se leen mientras ADS-B local está activo", + "idle": "en espera", + "running": "activo", + "suspended": "suspendido", + "closed": "cerrado", + "Local FM stopped: internet radio started": "FM local detenida: se inició la radio por Internet", + "ALPR cameras": "cámaras ALPR", + "OVERPASS RATE-LIMITED": "OVERPASS CON LÍMITE DE PETICIONES", + "OVERPASS TIMED OUT": "OVERPASS AGOTÓ EL TIEMPO DE ESPERA", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS NO PUDO COMPLETAR LA CONSULTA", + "MAP TILES TEMPORARILY UNAVAILABLE": "TESELAS DEL MAPA TEMPORALMENTE NO DISPONIBLES", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "NOMBRES MAPEADOS TEMPORALMENTE NO DISPONIBLES", + "Shared flight follow expired": "El seguimiento del vuelo compartido ha caducado", + "Shared military flight follow expired": "El seguimiento del vuelo militar compartido ha caducado", + "Shared satellite follow expired": "El seguimiento del satélite compartido ha caducado", + "Shared flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo compartido — fuente no disponible", + "Shared military flight could not be restored — feed unavailable": "No se pudo restaurar el vuelo militar compartido — fuente no disponible", + "Shared satellite could not be restored — feed unavailable": "No se pudo restaurar el satélite compartido — fuente no disponible", + "Shared flight is unavailable": "El vuelo compartido no está disponible", + "Shared military flight is unavailable": "El vuelo militar compartido no está disponible", + "Shared satellite is unavailable": "El satélite compartido no está disponible", + "Scene not saved — browser storage unavailable": "Escena no guardada — almacenamiento del navegador no disponible", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Escena no guardada — no se pudo leer el proyecto guardado. Exporta los cambios o importa un archivo válido.", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "No se pudo leer el proyecto guardado; el almacenamiento se conserva. Importa un archivo válido para volver a guardar.", + "Scene not saved — {reason}": "Escena no guardada — {reason}", + "Stopped": "Detenido", + "Stopped (Esc)": "Detenido (Esc)", + "Camera ownership changed": "Cambió el control de la cámara", + "Scene layer turned off": "Capa de la escena desactivada", + "Seeking scene clock": "Desplazando el reloj de la escena", + "Importing project": "Importando proyecto", + "Camera move interrupted": "Movimiento de cámara interrumpido", + "Scene media timed out": "El contenido multimedia de la escena agotó el tiempo de espera", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "El modo Misiones espaciales aísla los datos de repetición. Sal del modo para activar {layer}.", + "Tilted view": "Vista inclinada", + "Straight-down view": "Vista cenital", + "North up": "Norte arriba", + "Exit cockpit to fly to a camera": "Sal de la cabina para volar a una cámara", + "Exit cockpit to fly to a location": "Sal de la cabina para volar a una ubicación", + "Exit cockpit to fly to a photo": "Sal de la cabina para volar a una foto", + "Exit cockpit to fly to a shared view": "Sal de la cabina para volar a una vista compartida", + "Exit cockpit to fly to a target": "Sal de la cabina para volar a un objetivo", + "Exit cockpit to fly to a vessel": "Sal de la cabina para volar a un buque", + "Exit cockpit to fly to a fire": "Sal de la cabina para volar a un incendio", + "Exit cockpit to fly to a route": "Sal de la cabina para volar a una ruta", + "Exit cockpit to fly to a file": "Sal de la cabina para volar a un archivo", + "Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena", + "Exit cockpit to fly to a {noun}": "Sal de la cabina para volar a {noun}", + "Go to expanded Radio section": "Ir a la sección de Radio expandida", + "Expand Radio section in Context": "Expandir la sección de Radio en Contexto", + "Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio", + "New scene name": "Nombre de la nueva escena", + "Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?", + "Detailed outline unavailable": "Contorno detallado no disponible", + "Provider Settings does not answer proxied requests": "Ajustes de proveedores no responde a peticiones a través de un proxy", + "Provider Settings is disabled while sharing is enabled": "El panel \"Ajustes de proveedores\" está desactivado mientras el uso compartido esté activado", + "Provider Settings answers only the machine running the server": "Ajustes de proveedores solo responde al equipo que ejecuta el servidor", + "Provider Settings answers only local hostnames": "Ajustes de proveedores solo responde a nombres de host locales", + "Provider Settings requires an exact local Origin": "Ajustes de proveedores requiere un Origin local exacto", + "Unrecognized Origin refused": "Origin no reconocido: petición rechazada", + "Cross-origin requests are refused": "Se rechazan las peticiones de otro origen", + "Content-Type must be application/json": "Content-Type debe ser application/json", + "Method not allowed": "Método no permitido", + "Request too large": "Petición demasiado grande", + "Invalid JSON": "JSON no válido", + "Body must be a JSON object of {ENV_VAR: value}": "El cuerpo debe ser un objeto JSON de {ENV_VAR: value}", + "No keys provided": "No se envió ninguna clave", + "At most {limit} keys per save": "Como máximo {limit} claves por guardado", + "Unknown key: {name}": "Clave desconocida: {name}", + "{name} must be a string": "{name}: el valor debe ser una cadena de texto", + "{name} is empty": "{name}: el valor está vacío", + "{name} is longer than any real key ({limit} max)": "{name}: el valor es más largo que cualquier clave real (máximo {limit})", + "{name} may only contain printable characters with no spaces": "{name}: solo se admiten caracteres imprimibles, sin espacios", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: hay un carácter no válido en una clave (#, comillas, $, \\ o acento grave)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} se configuró fuera de Ajustes de proveedores y solo se puede cambiar donde se definió", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "La clave no se guardó: no se pudo restringir el archivo de credenciales a tu cuenta; no se guardó nada", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "La clave no se guardó: no se pudo leer la configuración existente, así que no se cambió nada", + "Could not write the {store} store": "No se pudo escribir en el almacén {store}", + "Could not check ChatGPT sign-in. Try again.": "No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.", + "Cloud voice will use OPENAI_API_KEY on the next session.": "La voz en la nube usará OPENAI_API_KEY en la próxima sesión.", + "Checking local ChatGPT OAuth sign-in…": "Comprobando el inicio de sesión OAuth local de ChatGPT…", + "Could not check ChatGPT sign-in.": "No se pudo comprobar el inicio de sesión de ChatGPT.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "OAuth de ChatGPT seleccionado para la voz en la nube. Tu clave de API sigue guardada y disponible.", + "ChatGPT sign-in expired. Opening sign-in again…": "El inicio de sesión de ChatGPT caducó. Abriéndolo de nuevo…", + "Opening ChatGPT sign-in in your browser…": "Abriendo el inicio de sesión de ChatGPT en tu navegador…", + "Could not start ChatGPT sign-in on this machine.": "No se pudo abrir el inicio de sesión de ChatGPT en este equipo.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Termina de iniciar sesión en ChatGPT en el navegador. Esperando a que se complete…", + "ChatGPT sign-in timed out. Click {button} to try again.": "Se agotó el tiempo para iniciar sesión en ChatGPT. Haz clic en \"{button}\" para intentarlo de nuevo.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Sesión de ChatGPT iniciada. Se usará OAuth en la próxima sesión de voz en la nube.", + "OAuth check failed: {detail}": "Error al comprobar OAuth: {detail}", + "unsaved CCTV calibration": "la calibración de CCTV sin guardar", + "the open cockpit": "la cabina abierta", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiar de idioma recarga la página. Se perderá lo siguiente: {items}. ¿Cambiar de todos modos?", + "Wind upstream unavailable": "Origen de datos de viento no disponible", + "WebUSB needs desktop Chrome or Edge": "WebUSB requiere Chrome o Edge de escritorio", + "connect a receiver in Radio": "conecta un receptor en Radio", + "receiver is in FM mode": "el receptor está en modo FM", + "Cyclone advisories unavailable": "Avisos de ciclones no disponibles", + "Cyclone data unavailable": "Datos de ciclones no disponibles", + "Drag to size the box": "Arrastra para dar tamaño al recuadro", + "Press on the ground, not the sky": "Pulsa sobre el suelo, no sobre el cielo", + "Point the camera at the ground to use the view": "Apunta la cámara al suelo para usar la vista", + "That point cannot anchor a box": "Ese punto no puede anclar un recuadro", + "Box edges must be finite": "Coordenadas del recuadro no válidas", + "Longitudes must be within ±180°": "Las longitudes deben estar dentro de ±180°", + "Select one side of the dateline": "No cruces la línea de cambio de fecha", + "Box must enclose an area": "El recuadro debe abarcar un área", + "Imagery stops at ±{limit}° latitude": "Las imágenes llegan hasta ±{limit}° de latitud", + "{owner} is using the pointer — close it first": "Puntero en uso: {owner}. Ciérralo primero", + "OpenSky OAuth client missing/invalid": "Cliente OAuth de OpenSky ausente o no válido", + "OpenSky OAuth rejected credentials": "OpenSky OAuth rechazó las credenciales", + "OpenSky username/password rejected": "OpenSky rechazó el usuario o la contraseña", + "OpenSky auth missing": "Falta la autenticación de OpenSky", + "OpenSky auth required": "OpenSky requiere autenticación", + "OpenSky auth invalid": "Autenticación de OpenSky no válida", + "OpenSky auth failed": "Falló la autenticación de OpenSky", + "Source snapshot {minutes} min old": "La instantánea de la fuente tiene {minutes} min", + "{source} rate limited (anonymous)": "{source}: límite de peticiones (anónimo)", + "{source} rate limited": "{source}: límite de peticiones", + "{source} HTTP {status}": "{source}: HTTP {status}", + "{source} network error": "{source}: error de red", + "Malformed {source} aircraft rows": "{source}: filas de aeronaves mal formadas", + "Malformed {source} response": "{source}: respuesta mal formada", + "Source snapshot time unavailable": "Hora de la instantánea de la fuente no disponible", + "Live data unavailable": "Datos en vivo no disponibles", + "{n} aircraft": { + "one": "{n} aeronave", + "many": "{n} de aeronaves", + "other": "{n} aeronaves" + }, + "{n} military aircraft": { + "one": "{n} aeronave militar", + "many": "{n} de aeronaves militares", + "other": "{n} aeronaves militares" + }, + "{n} ships": { + "one": "{n} buque", + "many": "{n} de buques", + "other": "{n} buques" + }, + "{n} satellites": { + "one": "{n} satélite", + "many": "{n} de satélites", + "other": "{n} satélites" + }, + "{n} launches": { + "one": "{n} lanzamiento", + "many": "{n} de lanzamientos", + "other": "{n} lanzamientos" + }, + "{n} earthquakes": { + "one": "{n} terremoto", + "many": "{n} de terremotos", + "other": "{n} terremotos" + }, + "{n} fires": { + "one": "{n} incendio", + "many": "{n} de incendios", + "other": "{n} incendios" + }, + "{n} fire perimeters": { + "one": "{n} perímetro de incendio", + "many": "{n} de perímetros de incendio", + "other": "{n} perímetros de incendio" + }, + "{n} cyclones": { + "one": "{n} ciclón", + "many": "{n} de ciclones", + "other": "{n} ciclones" + }, + "{n} buses": { + "one": "{n} autobús", + "many": "{n} de autobuses", + "other": "{n} autobuses" + }, + "{n} bike share": { + "one": "{n} estación de bicicletas compartidas", + "many": "{n} de estaciones de bicicletas compartidas", + "other": "{n} estaciones de bicicletas compartidas" + }, + "{n} flock cameras": { + "one": "{n} cámara ALPR", + "many": "{n} de cámaras ALPR", + "other": "{n} cámaras ALPR" + }, + "{n} military bases": { + "one": "{n} base militar", + "many": "{n} de bases militares", + "other": "{n} bases militares" + }, + "{n} datacenters": { + "one": "{n} centro de datos", + "many": "{n} de centros de datos", + "other": "{n} centros de datos" + }, + "{n} dams": { + "one": "{n} presa", + "many": "{n} de presas", + "other": "{n} presas" + }, + "{n} results": { + "one": "{n} resultado", + "many": "{n} de resultados", + "other": "{n} resultados" + }, + "anywhere in the loaded data": "en todos los datos cargados", + "in view": "a la vista", + "within {radius} of the view centre": "en un radio de {radius} del centro de la vista", + "within {radius} of {place} (Contacts window)": "en un radio de {radius} de {place} (ventana de Contactos)", + "within {radius} of {place}": "en un radio de {radius} de {place}", + "within {radius}": "en un radio de {radius}", + "over {region}": "en {region}", + "At least {items}": "Al menos {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "últimas 24 h", + "past 30 d and upcoming": "últimos 30 d y próximos", + "the flights layer loads by viewport": "la capa de vuelos se carga según la vista", + "distance is ground distance, not overhead": "la distancia es sobre el terreno hasta el punto subsatélite, no hasta el satélite", + "the count is a lower bound because at least one layer reached its retained limit.": "el recuento es un mínimo porque al menos una capa alcanzó su límite de retención.", + "{label} loading": "{label}: cargando", + "{label} error": "{label}: error", + "counted the first {returned} loaded records": "se contaron los primeros {returned} registros cargados", + "counted {returned} of {total} loaded records": "se contaron {returned} de {total} registros cargados", + "{layers} just turned on — still loading, counts will rise": "{layers}: recién activado — aún se está cargando, los recuentos subirán", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos ({time}) — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Instantánea del panel Contactos — el mismo conjunto retenido alimenta la tarjeta, la voz y la memoria de seguimiento", + "Not answered: {layers}": "Sin respuesta: {layers}", + "The flights layer is not enabled": "La capa de vuelos no está activada", + "The military layer is not enabled": "La capa de vuelos militares no está activada", + "The satellites layer is not enabled": "La capa de satélites no está activada", + "The ais-live-vessels layer is not enabled": "La capa de buques AIS no está activada", + "Could not enable the requested layer": "No se pudo activar la capa solicitada", + "Could not disable the requested layer": "No se pudo desactivar la capa solicitada", + "Radio layer unavailable": "La capa de radio no está disponible", + "Camera navigation is unavailable in the current view": "La navegación de la cámara no está disponible en la vista actual", + "Camera navigation policy unavailable": "La política de navegación de la cámara no está disponible", + "Nearest-aircraft selection supports Flights or Military Flights only": "La selección de la aeronave más cercana solo funciona con vuelos o vuelos militares", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud", + "Could not arrive at the requested place": "No se pudo llegar al lugar solicitado", + "The nearest airborne aircraft could not be selected": "No se pudo seleccionar la aeronave en vuelo más cercana", + "No flights within {radius} of the current view": "No hay vuelos en un radio de {radius} de la vista actual", + "No military within {radius} of the current view": "No hay vuelos militares en un radio de {radius} de la vista actual", + "No satellites within {radius} of the current view": "No hay satélites en un radio de {radius} de la vista actual", + "No vessels within {radius} of the current view": "No hay buques en un radio de {radius} de la vista actual", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada con la fuente {source}, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "La capa {layer} está activada, pero aún no hay aeronaves en vuelo cargadas en la vista de {place}", + "{layer} is enabled, but its aircraft feed is unavailable": "La capa {layer} está activada, pero su fuente de aeronaves no está disponible", + "{layer} is enabled, but its destination refresh did not complete": "La capa {layer} está activada, pero la actualización en el destino no terminó", + "{layer} is enabled, but {source} is unavailable": "La capa {layer} está activada, pero {source} no está disponible", + "Could not arrive at {place}": "No se pudo llegar a {place}", + "{name} is no longer loaded": "{name} ya no está cargado", + "Unknown target layer: {target}": "Capa de destino desconocida: {target}", + "Layer": "Capa", + "there": "allí", + "Frame flights": "Encuadrar aeronaves", + "Frame military": "Encuadrar aeronaves militares", + "Frame vessels": "Encuadrar buques", + "Fly to Location": "Volar al lugar", + "Nearest aircraft to Here": "Aeronave más cercana aquí", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "La estimación está incompleta: una respuesta seguía en curso cuando terminó la sesión, así que su uso nunca se informó.", + "STANDARD": "ESTÁNDAR", + "vehicle motion|Stopped": "Detenido", + "ACQUIRING FRAME": "OBTENIENDO FOTOGRAMA", + "Camera source temporarily unavailable": "Fuente de cámaras no disponible temporalmente", + "Perimeter source unavailable": "Fuente de perímetros no disponible", + "Imagery catalog unavailable": "Catálogo de imágenes no disponible", + "Earthquake source unavailable": "Fuente de terremotos no disponible", + "TeleGeography load failed": "Falló la carga de TeleGeography", + "Wind request timed out": "La petición del viento agotó el tiempo de espera", + "Malformed wind manifest": "Manifiesto del viento mal formado", + "Malformed wind grid": "Malla del viento mal formada", + "Unknown wind model": "Modelo de viento desconocido", + "Unknown weather overlay": "Capa de clima desconocida", + "Temperature field unavailable": "Campo de temperatura no disponible", + "Mean sea level pressure field unavailable": "Campo de presión a nivel del mar no disponible", + "KartaView answered HTTP {status}": "KartaView respondió HTTP {status}", + "KartaView refused the request (code {code})": "KartaView rechazó la petición (código {code})", + "Stopped by voice": "Detenido por voz", + "drawings on the map": "los dibujos del mapa", + "imported map files": "los archivos importados en el mapa", + "the active voice session": "la sesión de voz activa", + "Light aircraft": "Avioneta", + "Glider": "Planeador", + "Turboprop": "Turbohélice", + "Airliner": "Avión comercial", + "Widebody": "Avión de fuselaje ancho", + "Four-engine jet": "Reactor cuatrimotor", + "Helicopter": "Helicóptero", + "Fast jet": "Avión de alto rendimiento", + "Business jet": "Reactor ejecutivo", + "Drone": "Dron", + "fire cause|Human": "humana", + "fire cause|Natural": "natural", + "fire cause|Undetermined": "indeterminada", + "fire cause|Unknown": "desconocida", + "fire behavior|Minimal": "actividad mínima", + "fire behavior|Moderate": "actividad moderada", + "fire behavior|Active": "actividad alta", + "fire behavior|Extreme": "actividad extrema", + "Type {level} Incident": "incidente de tipo {level}", + "${amount}K": "{amount} mil $", + "${amount}M": "{amount} M$", + "${amount}B": "{amount} mil M$", + "{speed} kn": "{speed} kn", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "feed status|live": "en vivo", + "feed status|stale": "desactualizada", + "feed status|unreachable": "inaccesible", + "feed status|invalid": "no válida", + "Vector tile metadata unavailable": "Metadatos de teselas vectoriales no disponibles", + "Vector tiles unavailable": "Teselas vectoriales no disponibles", + "Vector tiles unavailable (HTTP {status})": "Teselas vectoriales no disponibles (HTTP {status})", + "Vector tile request timed out": "Las teselas vectoriales agotaron el tiempo de espera", + "Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales", + "Camera coverage unavailable": "Cobertura de cámaras no disponible", + "Hybrid": "Híbrido", + "TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)", + "OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primero las vías de TomTom; OpenStreetMap completa el resto con flujo simulado (necesita una clave de TomTom)", + "Austin": "Austin", + "San Francisco": "San Francisco", + "New York": "Nueva York", + "Tokyo": "Tokio", + "London": "Londres", + "Paris": "París", + "Dubai": "Dubái", + "Washington DC": "Washington D. C.", + "Tallinn": "Tallin", + "Los Angeles": "Los Ángeles", + "Data attribution": "Atribución de datos", + "Data provided by:": "Datos proporcionados por:", + "Exit cockpit to view weather coverage": "Sal de la cabina para ver la cobertura de la capa meteorológica", + "Exit cockpit to fly to a cyclone": "Sal de la cabina para volar a un ciclón", + "the title of scene {scene} is empty or longer than {max} characters": "el título de la escena {scene} está vacío o supera los {max} caracteres", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "el título del plano {shot} de la escena {scene} está vacío o supera los {max} caracteres", + "the project is larger than 5 MiB": "el proyecto pesa más de 5 MiB", + "the project has too many shots": "el proyecto tiene demasiados planos", + "the scene data failed validation": "los datos de la escena no superaron la validación", + "Layer on": "Capa: activada", + "Layer off": "Capa: desactivada", + "Zoom in": "Acercar", + "Zoom out": "Alejar", + "Zoom out to globe": "Alejar hasta el globo", + "Open data layers": "Abrir las capas de datos", + "Open or close a panel": "Abrir o cerrar un panel", + "Change context mode": "Cambiar el modo de contexto", + "Control cockpit": "Controlar la cabina", + "Change visual style": "Cambiar el estilo visual", + "Adjust HUD": "Ajustar el HUD", + "Adjust cyber sonar": "Ajustar el sonar Ciber", + "Adjust detection overlay": "Ajustar la superposición de detección", + "Change map stack": "Cambiar el conjunto de mapas", + "Adjust post-processing": "Ajustar el posprocesado", + "Control scene": "Controlar la escena", + "Control CCTV": "Controlar las cámaras CCTV", + "Control radio": "Controlar la radio", + "Track contact": "Rastrear el contacto", + "Stop tracking": "Detener el rastreo", + "Clear marks": "Borrar las marcas", + "Move camera": "Mover la cámara", + "Fly route": "Recorrer la ruta", + "Analyst query": "Consulta analítica", + "Next ISS pass": "Próximo paso de la EEI", + "Next satellite pass": "Próximo paso del satélite", + "Action": "Acción" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f51a97364..0b3e4186b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -857,7 +857,7 @@ "Voice session could not be started.": "Не удалось запустить голосовую сессию.", "CONNECTING": "ПОДКЛЮЧЕНИЕ", "ERROR": "ОШИБКА", - "{tier} applies next session": "{tier} вступит в силу со следующей сессии", + "{tier} applies next session": "{tier} — со следующей сессии", "Session ended — cost cap {cost}": "Сессия завершена — лимит стоимости {cost}", "Next session: {pendingId} — this session stays on {modelId}": "Следующая сессия: {pendingId} — текущая остаётся на {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "Голосовая модель: {pendingId} — нажмите, чтобы переключиться на {target}; вступит в силу со следующей сессии", @@ -1001,7 +1001,7 @@ "Coverage colours": "Цвета покрытия", "That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.", "Could not place the shape: {error}": "Не удалось разместить фигуру: {error}", - "{owner} is using the pointer — close it first.": "Указатель занят инструментом {owner} — сначала закройте его.", + "{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.", "{label} · Detailed outline unavailable": "{label} · Подробный контур недоступен", "Witness source": "Свидетельство очевидца", "{provider} · EMBEDDED SOURCE": "{provider} · ВСТРОЕННЫЙ ИСТОЧНИК", @@ -1790,7 +1790,7 @@ "Mark {targets} +{more}": "Отметить {targets} +{more}", "Mark {targets}": "Отметить {targets}", "Fly to {place}": "Перелёт: {place}", - "Nearest aircraft to {place}": "Ближайшие самолёты: {place}", + "Nearest aircraft to {place}": "Ближайший самолёт: {place}", "Read the view": "Осмотреть видимую область", "Check view state": "Проверить состояние вида", "Frame {target}": "Показать в кадре: {target}", @@ -2244,5 +2244,579 @@ "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показать на глобусе свои файлы GeoJSON, KML, KMZ или GPX (или перетащите их на глобус). Содержимое файлов остаётся на этом устройстве; наложения могут попасть в снимки карты, которые передаются голосовому ассистенту.", "Import": "Импорт", "Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX", - "Imported files": "Импортированные файлы" + "Imported files": "Импортированные файлы", + "DOCKED · {name}": "СТЫКОВКА · {name}", + "DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}", + "loading coverage...": "загрузка покрытия...", + "live feed unavailable": "поток в реальном времени недоступен", + "GBFS fetch error": "ошибка загрузки GBFS", + "GBFS status update failed": "не удалось обновить статус GBFS", + "Open the Street Level panel to view imagery": "Откройте панель «Уличные снимки», чтобы их посмотреть", + "Image could not be opened": "Не удалось открыть снимок", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary отклонил MAPILLARY_CLIENT_TOKEN — замените его в «Настройках провайдеров»", + "Coverage requests are being rate-limited — they resume on their own": "Запросы покрытия ограничены по частоте — они возобновятся сами", + "No street-level imagery within {radius} m of that point": "В радиусе {radius} м от этой точки уличных снимков нет", + "No street-level imagery within {radius} m of the view centre": "В радиусе {radius} м от центра вида уличных снимков нет", + "Aim the middle of the view at a street to look for imagery there": "Наведите центр вида на улицу, чтобы поискать там снимки", + "Street View has no imagery for this place": "В Просмотре улиц нет снимков этого места", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан", + "The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API", + "Weather request timed out": "Истекло время ожидания запроса погоды", + "Malformed weather manifest": "Некорректный манифест погоды", + "Weather HTTP {status}": "Погода: HTTP {status}", + "Weather image HTTP {status}": "Снимок погоды: HTTP {status}", + "Weather prefetch unavailable": "Предзагрузка погоды недоступна", + "Infrared decode failed": "Не удалось декодировать инфракрасный снимок", + "rate limited": "превышен лимит запросов", + "routing service is rate limited": "сервис маршрутизации превысил лимит запросов", + "route leg too long": "участок маршрута слишком длинный", + "route too long": "маршрут слишком длинный", + "Routing is rate limited — try again in a moment": "Построение маршрута: превышен лимит запросов — попробуйте ещё раз чуть позже", + "{error} — try again in a moment": "{error} — попробуйте ещё раз чуть позже", + "Routing unavailable (HTTP {status})": "Построение маршрута недоступно (HTTP {status})", + "CelesTrak unreachable": "CelesTrak недоступен", + "KartaView is rate-limiting requests — they resume on their own": "KartaView ограничивает частоту запросов — они возобновятся сами", + "KartaView’s photos here are archived and cannot be shown": "Фотографии KartaView здесь в архиве, показать их нельзя", + "KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова", + "None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков", + "KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова", + "KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть", + "KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать", + "KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов", + "KartaView is rate-limiting requests — try again in a moment": "KartaView ограничивает частоту запросов — попробуйте ещё раз чуть позже", + "KartaView sent too much data": "KartaView прислал слишком много данных", + "KartaView has no such photo": "У KartaView нет такой фотографии", + "The KartaView photo could not be loaded": "Не удалось загрузить фотографию KartaView", + "KartaView has no position for this photo": "У KartaView нет координат этой фотографии", + "Map tiles temporarily unavailable": "Тайлы карты временно недоступны", + "Mapped names temporarily unavailable": "Названия объектов временно недоступны", + "No mapped sites in view": "В кадре нет объектов", + "No mapped sites within {km} km of the contact": "Нет объектов в радиусе {km} км от контакта", + "{count} mapped sites in view": { + "one": "{count} объект в кадре", + "few": "{count} объекта в кадре", + "many": "{count} объектов в кадре", + "other": "{count} объекта в кадре" + }, + "{count} mapped sites within {km} km of the contact": { + "one": "{count} объект в радиусе {km} км от контакта", + "few": "{count} объекта в радиусе {km} км от контакта", + "many": "{count} объектов в радиусе {km} км от контакта", + "other": "{count} объекта в радиусе {km} км от контакта" + }, + "Too many mapped sites in view to list them all": "В кадре слишком много объектов, чтобы перечислить все", + "Installation context unavailable": "Контекст объектов недоступен", + "Focus vessel {name}, MMSI {mmsi}": "Фокус на судне {name}, MMSI {mmsi}", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Открыть первичное сообщение USGS об оползне в Непале 26 августа 2026 года", + "YouTube source": "Источник YouTube", + "Facebook source": "Источник Facebook", + "Military airfield": "Военный аэродром", + "Naval base": "Военно-морская база", + "Firing range": "Стрельбище", + "Military land": "Военная территория", + "Mapped installation": "Объект на карте", + "Military area": "Военная зона", + "SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}", + "TomTom key unavailable": "Ключ TomTom недоступен", + "TomTom daily budget reached": "Исчерпан дневной лимит TomTom", + "TomTom upstream unreachable": "Сервер TomTom не отвечает", + "TomTom flow unavailable": "Поток TomTom недоступен", + "API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "нет данных AIS от потока {seconds} с", + "feed silent — no AIS data": "нет данных AIS от потока", + "feed down — retrying slowly (attempt {attempt})": "поток недоступен — повторяем реже (попытка {attempt})", + "feed down — retrying slowly": "поток недоступен — повторяем реже", + "reconnecting to feed… (attempt {attempt})": "переподключение к потоку… (попытка {attempt})", + "reconnecting to feed…": "переподключение к потоку…", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY не задан", + "live feed unsupported": "поток в реальном времени не поддерживается", + "connecting to feed…": "подключение к потоку…", + "feed disconnected": "поток отключён", + "feed down": "поток недоступен", + "feed idle": "поток простаивает", + "AIS live load failed": "Не удалось загрузить AIS в реальном времени", + "age|{count}m": "{count} мин", + "{acres} ac": { + "one": "{acres} акр", + "few": "{acres} акра", + "many": "{acres} акров", + "other": "{acres} акра" + }, + "{percent}% contained": "локализован на {percent}%", + "containment unknown": "локализация неизвестна", + "{cause} cause": "причина: {cause}", + "{personnel} personnel": { + "one": "{personnel} человек", + "few": "{personnel} человека", + "many": "{personnel} человек", + "other": "{personnel} человека" + }, + "{county} County": "округ {county}", + "{cost} to date": "{cost} на сегодня", + "discovered {age} ago": "обнаружен {age} назад", + "updated {age} ago": "обновлён {age} назад", + "part of {complex}": "входит в {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · нажмите на карточку, чтобы открыть", + "FIRE · {name}": "ПОЖАР · {name}", + "Unnamed incident": "Без названия", + "Open {title} on InciWeb": "Открыть {title} в InciWeb", + "Vehicle {vehicle}": "Борт {vehicle}", + "Bus": "Автобус", + "Tram": "Трамвай", + "Subway": "Метро", + "Train": "Поезд", + "Ferry": "Паром", + "Transit vehicle": "Общественный транспорт", + "No recent fix": "Нет свежих координат", + "Waiting for update": "Ожидание обновления", + "Moving": "В движении", + "reported {speed} km/h": "по отчёту {speed} км/ч", + "reported hdg {heading}°": "по отчёту курс {heading}°", + "Last report: stopped at stop {stop}": "Последний отчёт: стоит на остановке {stop}", + "Last report: arriving at stop {stop}": "Последний отчёт: прибывает на остановку {stop}", + "Last report: next stop {stop}": "Последний отчёт: следующая остановка {stop}", + "occupancy|empty": "пусто", + "occupancy|many seats available": "много свободных мест", + "occupancy|few seats available": "мало свободных мест", + "occupancy|standing room only": "только стоячие места", + "occupancy|crushed standing room only": "переполнен, только стоя", + "occupancy|full": "мест нет", + "occupancy|not accepting passengers": "посадки нет", + "occupancy|not boardable": "посадка невозможна", + "Shown at street level · depth not in the feed": "Показан на уровне улицы · глубины в потоке нет", + "Reported {seconds} s ago": "Отчёт {seconds} с назад", + "Reported {minutes} min ago": "Отчёт {minutes} мин назад", + "{reported} (feed time)": "{reported} (время потока)", + "shown {seconds} s behind": "показ отстаёт на {seconds} с", + "Available trail: {minutes} min": "Доступный след: {minutes} мин", + "Available trail: {minutes} min (limited)": "Доступный след: {minutes} мин (ограничен)", + "UNAVAILABLE · {source} · Roads unavailable": "НЕДОСТУПНО · {source} · Дороги недоступны", + "Roads: {source}": "Дороги: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "Дороги TomTom недоступны, пока сервис трафика не отвечает", + "Hybrid unavailable while the traffic service is unreachable": "Гибридный режим недоступен, пока сервис трафика не отвечает", + "TomTom roads need a TomTom key": "Для дорог TomTom нужен ключ TomTom", + "Hybrid needs a TomTom key": "Для гибридного режима нужен ключ TomTom", + "Local surface still loading": "Поверхность под дорогами ещё загружается", + "Partial coverage": "Частичное покрытие", + "Detailed roads unavailable": "Нет детальных данных о дорогах", + "Reduced detail coverage": "Покрытие с меньшей детализацией", + "OpenFreeMap tiles rate-limited": "Тайлы OpenFreeMap: превышен лимит запросов", + "OpenFreeMap tiles timed out": "Тайлы OpenFreeMap: истекло время ожидания", + "OpenFreeMap tiles unavailable": "Тайлы OpenFreeMap недоступны", + "Heard by your receiver": "Принято вашим приёмником", + "browser SDR": "SDR в браузере", + "decoder feed": "поток декодера", + "Your RTL-SDR receiver and decoder feed": "Ваш приёмник RTL-SDR и поток декодера", + "Your decoder feed": "Ваш поток декодера", + "Your RTL-SDR receiver": "Ваш приёмник RTL-SDR", + "NO CALLSIGN": "БЕЗ ПОЗЫВНОГО", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ВЫС. {altitude} ФТ · ПУТ. СКОР. {speed} УЗ · ПУТЬ {track}", + "V/S {rate}": "ВЕРТ. СКОР. {rate}", + "V/S {rate} FPM": "ВЕРТ. СКОР. {rate} ФТ/МИН", + "POSITION {age}": "ПОЗИЦИЯ {age}", + "POSITION {seconds} S AGO": "ПОЗИЦИЯ {seconds} С НАЗАД", + "{count} MSGS": { + "one": "{count} СООБЩЕНИЕ", + "few": "{count} СООБЩЕНИЯ", + "many": "{count} СООБЩЕНИЙ", + "other": "{count} СООБЩЕНИЯ" + }, + "installation class|BARRACKS": "КАЗАРМЫ", + "installation class|TRAINING AREA": "УЧЕБНЫЙ ПОЛИГОН", + "Seeking up…": "Поиск вверх…", + "Seeking down…": "Поиск вниз…", + "Scanning {mhz} MHz…": "Сканирование {mhz} МГц…", + "Found {mhz} MHz · {snr} dB": "Найдено {mhz} МГц · {snr} дБ", + "Broadcaster stream is unavailable or blocked by the browser.": "Поток вещателя недоступен или заблокирован браузером.", + "Playback requires a direct click or tap.": "Чтобы начать воспроизведение, нажмите «Пуск».", + "Broadcaster stream could not be started.": "Не удалось запустить поток вещателя.", + "Directory refresh degraded; showing the previous station catalog.": "Обновление каталога прошло с перебоями; показан предыдущий каталог станций.", + "Radio directory coverage is degraded.": "Каталог радиостанций работает с перебоями.", + "Directory refresh failed; showing the previous station catalog.": "Не удалось обновить каталог; показан предыдущий каталог станций.", + "Radio directory is temporarily unavailable.": "Каталог радиостанций временно недоступен.", + "Decoder feeds: {feeds}": "Потоки декодера: {feeds}", + " · read while Local ADS-B is on": " · читаются при включённом слое «Локальный ADS-B»", + "idle": "ожидание", + "running": "работает", + "suspended": "приостановлено", + "closed": "закрыто", + "Local FM stopped: internet radio started": "Локальный FM остановлен: запущено интернет-радио", + "ALPR cameras": "камеры ALPR", + "OVERPASS RATE-LIMITED": "OVERPASS: ПРЕВЫШЕН ЛИМИТ ЗАПРОСОВ", + "OVERPASS TIMED OUT": "OVERPASS: ИСТЕКЛО ВРЕМЯ ОЖИДАНИЯ", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS: НЕ УДАЛОСЬ ЗАВЕРШИТЬ ЗАПРОС", + "MAP TILES TEMPORARILY UNAVAILABLE": "ТАЙЛЫ КАРТЫ ВРЕМЕННО НЕДОСТУПНЫ", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "НАЗВАНИЯ ОБЪЕКТОВ ВРЕМЕННО НЕДОСТУПНЫ", + "Shared flight follow expired": "Срок отслеживания рейса по ссылке истёк", + "Shared military flight follow expired": "Срок отслеживания военного рейса по ссылке истёк", + "Shared satellite follow expired": "Срок отслеживания спутника по ссылке истёк", + "Shared flight could not be restored — feed unavailable": "Не удалось восстановить рейс по ссылке — поток данных недоступен", + "Shared military flight could not be restored — feed unavailable": "Не удалось восстановить военный рейс по ссылке — поток данных недоступен", + "Shared satellite could not be restored — feed unavailable": "Не удалось восстановить спутник по ссылке — поток данных недоступен", + "Shared flight is unavailable": "Рейс по ссылке недоступен", + "Shared military flight is unavailable": "Военный рейс по ссылке недоступен", + "Shared satellite is unavailable": "Спутник по ссылке недоступен", + "Scene not saved — browser storage unavailable": "Сцена не сохранена — хранилище браузера недоступно", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Сцена не сохранена — не удалось прочитать сохранённый проект. Экспортируйте правки или импортируйте корректный файл.", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Не удалось прочитать сохранённый проект; хранилище не тронуто. Импортируйте корректный файл, чтобы снова сохранять.", + "Scene not saved — {reason}": "Сцена не сохранена — {reason}", + "Stopped": "Остановлено", + "Stopped (Esc)": "Остановлено (Esc)", + "Camera ownership changed": "Управление камерой перехвачено", + "Scene layer turned off": "Слой сцены выключен", + "Seeking scene clock": "Перемотка сцены", + "Importing project": "Импорт проекта", + "Camera move interrupted": "Перелёт камеры прерван", + "Scene media timed out": "Истекло время ожидания медиа сцены", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "Режим «Космические миссии» изолирует данные воспроизведения. Выйдите из режима, чтобы включить слой «{layer}».", + "Tilted view": "Вид с наклоном", + "Straight-down view": "Вид сверху", + "North up": "Север вверху", + "Exit cockpit to fly to a camera": "Выйдите из кабины, чтобы перелететь к камере", + "Exit cockpit to fly to a location": "Выйдите из кабины, чтобы перелететь к месту", + "Exit cockpit to fly to a photo": "Выйдите из кабины, чтобы перелететь к фото", + "Exit cockpit to fly to a shared view": "Выйдите из кабины, чтобы перелететь к виду по ссылке", + "Exit cockpit to fly to a target": "Выйдите из кабины, чтобы перелететь к цели", + "Exit cockpit to fly to a vessel": "Выйдите из кабины, чтобы перелететь к судну", + "Exit cockpit to fly to a fire": "Выйдите из кабины, чтобы перелететь к пожару", + "Exit cockpit to fly to a route": "Выйдите из кабины, чтобы перелететь к маршруту", + "Exit cockpit to fly to a file": "Выйдите из кабины, чтобы перелететь к файлу", + "Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене", + "Exit cockpit to fly to a {noun}": "Выйдите из кабины, чтобы перелететь: {noun}", + "Go to expanded Radio section": "Перейти к развёрнутому разделу радио", + "Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»", + "Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место", + "New scene name": "Название новой сцены", + "Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?", + "Detailed outline unavailable": "Подробный контур недоступен", + "Provider Settings does not answer proxied requests": "«Настройки провайдеров» не отвечают на запросы через прокси", + "Provider Settings is disabled while sharing is enabled": "«Настройки провайдеров» отключены, пока включён общий доступ", + "Provider Settings answers only the machine running the server": "«Настройки провайдеров» отвечают только компьютеру, на котором запущен сервер", + "Provider Settings answers only local hostnames": "«Настройки провайдеров» отвечают только на локальные имена хостов", + "Provider Settings requires an exact local Origin": "«Настройкам провайдеров» нужен точный локальный Origin", + "Unrecognized Origin refused": "Нераспознанный Origin отклонён", + "Cross-origin requests are refused": "Запросы с другого источника отклоняются", + "Content-Type must be application/json": "Content-Type должен быть application/json", + "Method not allowed": "Метод не разрешён", + "Request too large": "Слишком большой запрос", + "Invalid JSON": "Некорректный JSON", + "Body must be a JSON object of {ENV_VAR: value}": "Тело запроса должно быть JSON-объектом вида {ENV_VAR: value}", + "No keys provided": "Ключи не переданы", + "At most {limit} keys per save": "Ключей за одно сохранение — не больше {limit}", + "Unknown key: {name}": "Неизвестный ключ: {name}", + "{name} must be a string": "{name}: значение должно быть строкой", + "{name} is empty": "{name}: пустое значение", + "{name} is longer than any real key ({limit} max)": "{name}: значение длиннее любого настоящего ключа (максимум {limit})", + "{name} may only contain printable characters with no spaces": "{name}: допустимы только печатные символы без пробелов", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name}: недопустимый для ключа символ (#, кавычки, $, \\ или обратный апостроф)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name}: задан вне «Настроек провайдеров», изменить его можно только там, где его задали", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "Ключ не сохранён: не удалось закрыть файл учётных данных от всех, кроме вашей учётной записи; ничего не сохранено", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "Ключ не сохранён: не удалось прочитать текущую конфигурацию, поэтому ничего не изменено", + "Could not write the {store} store": "Не удалось записать хранилище {store}", + "Could not check ChatGPT sign-in. Try again.": "Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.", + "Cloud voice will use OPENAI_API_KEY on the next session.": "Следующая облачная голосовая сессия будет использовать OPENAI_API_KEY.", + "Checking local ChatGPT OAuth sign-in…": "Проверка локального входа в ChatGPT через OAuth…", + "Could not check ChatGPT sign-in.": "Не удалось проверить вход в ChatGPT.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "Для облачного голоса выбран ChatGPT OAuth. Ваш ключ API остаётся сохранённым и доступным.", + "ChatGPT sign-in expired. Opening sign-in again…": "Сеанс ChatGPT истёк. Открываем вход заново…", + "Opening ChatGPT sign-in in your browser…": "Открываем вход в ChatGPT в браузере…", + "Could not start ChatGPT sign-in on this machine.": "Не удалось начать вход в ChatGPT на этом компьютере.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Завершите вход в ChatGPT в браузере. Ожидание завершения…", + "ChatGPT sign-in timed out. Click {button} to try again.": "Время на вход в ChatGPT истекло. Нажмите «{button}», чтобы попробовать ещё раз.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "Вход в ChatGPT выполнен. В следующей облачной голосовой сессии будет использоваться OAuth.", + "OAuth check failed: {detail}": "Не удалось проверить OAuth: {detail}", + "unsaved CCTV calibration": "несохранённая калибровка CCTV", + "the open cockpit": "открытая кабина", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Смена языка перезагрузит страницу. Будет потеряно: {items}. Всё равно сменить язык?", + "Wind upstream unavailable": "Источник данных о ветре недоступен", + "WebUSB needs desktop Chrome or Edge": "Для WebUSB нужен настольный Chrome или Edge", + "connect a receiver in Radio": "подключите приёмник в разделе «Радио»", + "receiver is in FM mode": "приёмник в режиме FM", + "Cyclone advisories unavailable": "Сводки о циклонах недоступны", + "Cyclone data unavailable": "Данные о циклонах недоступны", + "Drag to size the box": "Потяните, чтобы задать размер области", + "Press on the ground, not the sky": "Нажмите на землю, а не на небо", + "Point the camera at the ground to use the view": "Наведите камеру на землю, чтобы использовать вид", + "That point cannot anchor a box": "От этой точки нельзя построить область", + "Box edges must be finite": "Некорректные координаты области", + "Longitudes must be within ±180°": "Долгота должна быть в пределах ±180°", + "Select one side of the dateline": "Не пересекайте линию перемены дат", + "Box must enclose an area": "Область должна иметь площадь", + "Imagery stops at ±{limit}° latitude": "Снимки есть только до ±{limit}° широты", + "{owner} is using the pointer — close it first": "Указатель занят: «{owner}». Закройте его", + "OpenSky OAuth client missing/invalid": "Клиент OAuth OpenSky отсутствует или недействителен", + "OpenSky OAuth rejected credentials": "OpenSky OAuth отклонил учётные данные", + "OpenSky username/password rejected": "OpenSky отклонил имя пользователя или пароль", + "OpenSky auth missing": "Нет данных для входа в OpenSky", + "OpenSky auth required": "OpenSky требует входа", + "OpenSky auth invalid": "Неверные данные для входа в OpenSky", + "OpenSky auth failed": "Не удалось войти в OpenSky", + "Source snapshot {minutes} min old": "Возраст снимка источника: {minutes} мин", + "{source} rate limited (anonymous)": "{source}: лимит запросов (анонимно)", + "{source} rate limited": "{source}: лимит запросов", + "{source} HTTP {status}": "{source}: HTTP {status}", + "{source} network error": "{source}: ошибка сети", + "Malformed {source} aircraft rows": "{source}: некорректные записи о самолётах", + "Malformed {source} response": "{source}: некорректный ответ", + "Source snapshot time unavailable": "Время снимка источника неизвестно", + "Live data unavailable": "Данные в реальном времени недоступны", + "{n} aircraft": { + "one": "{n} самолёт", + "few": "{n} самолёта", + "many": "{n} самолётов", + "other": "{n} самолёта" + }, + "{n} military aircraft": { + "one": "{n} военный самолёт", + "few": "{n} военных самолёта", + "many": "{n} военных самолётов", + "other": "{n} военного самолёта" + }, + "{n} ships": { + "one": "{n} судно", + "few": "{n} судна", + "many": "{n} судов", + "other": "{n} судна" + }, + "{n} satellites": { + "one": "{n} спутник", + "few": "{n} спутника", + "many": "{n} спутников", + "other": "{n} спутника" + }, + "{n} launches": { + "one": "{n} пуск", + "few": "{n} пуска", + "many": "{n} пусков", + "other": "{n} пуска" + }, + "{n} earthquakes": { + "one": "{n} землетрясение", + "few": "{n} землетрясения", + "many": "{n} землетрясений", + "other": "{n} землетрясения" + }, + "{n} fires": { + "one": "{n} пожар", + "few": "{n} пожара", + "many": "{n} пожаров", + "other": "{n} пожара" + }, + "{n} fire perimeters": { + "one": "{n} контур пожара", + "few": "{n} контура пожаров", + "many": "{n} контуров пожаров", + "other": "{n} контура пожаров" + }, + "{n} cyclones": { + "one": "{n} циклон", + "few": "{n} циклона", + "many": "{n} циклонов", + "other": "{n} циклона" + }, + "{n} buses": { + "one": "{n} автобус", + "few": "{n} автобуса", + "many": "{n} автобусов", + "other": "{n} автобуса" + }, + "{n} bike share": { + "one": "{n} станция проката велосипедов", + "few": "{n} станции проката велосипедов", + "many": "{n} станций проката велосипедов", + "other": "{n} станции проката велосипедов" + }, + "{n} flock cameras": { + "one": "{n} камера ALPR", + "few": "{n} камеры ALPR", + "many": "{n} камер ALPR", + "other": "{n} камеры ALPR" + }, + "{n} military bases": { + "one": "{n} военная база", + "few": "{n} военные базы", + "many": "{n} военных баз", + "other": "{n} военной базы" + }, + "{n} datacenters": { + "one": "{n} дата-центр", + "few": "{n} дата-центра", + "many": "{n} дата-центров", + "other": "{n} дата-центра" + }, + "{n} dams": { + "one": "{n} плотина", + "few": "{n} плотины", + "many": "{n} плотин", + "other": "{n} плотины" + }, + "{n} results": { + "one": "{n} результат", + "few": "{n} результата", + "many": "{n} результатов", + "other": "{n} результата" + }, + "anywhere in the loaded data": "во всех загруженных данных", + "in view": "в кадре", + "within {radius} of the view centre": "в радиусе {radius} от центра вида", + "within {radius} of {place} (Contacts window)": "в радиусе {radius} — {place} (окно «Контактов»)", + "within {radius} of {place}": "в радиусе {radius} — {place}", + "within {radius}": "в радиусе {radius}", + "over {region}": "в регионе {region}", + "At least {items}": "{items} или больше", + "{items} {scope}": "{items} {scope}", + "last 24 h": "за последние 24 ч", + "past 30 d and upcoming": "за последние 30 дн. и предстоящие", + "the flights layer loads by viewport": "слой рейсов загружается по области вида", + "distance is ground distance, not overhead": "расстояние — по поверхности земли до подспутниковой точки, а не до самого аппарата", + "the count is a lower bound because at least one layer reached its retained limit.": "это нижняя граница: хотя бы один слой достиг предела хранимых записей.", + "{label} loading": "{label}: загрузка", + "{label} error": "{label}: ошибка", + "counted the first {returned} loaded records": "учтены только первые загруженные записи: {returned}", + "counted {returned} of {total} loaded records": "учтено загруженных записей: {returned} из {total}", + "{layers} just turned on — still loading, counts will rise": "{layers}: только что включено — данные ещё загружаются, значения вырастут", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» ({time}) — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Снимок панели «Контакты» — та же сохранённая выборка используется для карточки, речи и памяти уточняющих вопросов", + "Not answered: {layers}": "Нет ответа: {layers}", + "The flights layer is not enabled": "Слой рейсов не включён", + "The military layer is not enabled": "Слой военных рейсов не включён", + "The satellites layer is not enabled": "Слой спутников не включён", + "The ais-live-vessels layer is not enabled": "Слой судов AIS не включён", + "Could not enable the requested layer": "Не удалось включить запрошенный слой", + "Could not disable the requested layer": "Не удалось выключить запрошенный слой", + "Radio layer unavailable": "Слой радио недоступен", + "Camera navigation is unavailable in the current view": "Управление камерой недоступно в текущем виде", + "Camera navigation policy unavailable": "Правила навигации камеры недоступны", + "Nearest-aircraft selection supports Flights or Military Flights only": "Выбор ближайшего самолёта работает только для рейсов и военных рейсов", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота", + "Could not arrive at the requested place": "Не удалось долететь до запрошенного места", + "The nearest airborne aircraft could not be selected": "Не удалось выбрать ближайший самолёт в воздухе", + "No flights within {radius} of the current view": "В радиусе {radius} от текущего вида нет рейсов", + "No military within {radius} of the current view": "В радиусе {radius} от текущего вида нет военных рейсов", + "No satellites within {radius} of the current view": "В радиусе {radius} от текущего вида нет спутников", + "No vessels within {radius} of the current view": "В радиусе {radius} от текущего вида нет судов", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено (поток {source}), но в виде «{place}» пока не загружено ни одного самолёта в воздухе", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer}: включено, но в виде «{place}» пока не загружено ни одного самолёта в воздухе", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer}: включено, но поток данных о самолётах недоступен", + "{layer} is enabled, but its destination refresh did not complete": "{layer}: включено, но обновление в точке назначения не завершилось", + "{layer} is enabled, but {source} is unavailable": "{layer}: включено, но источник {source} недоступен", + "Could not arrive at {place}": "Не удалось долететь: {place}", + "{name} is no longer loaded": "{name}: объект больше не загружен", + "Unknown target layer: {target}": "Неизвестный слой цели: {target}", + "Layer": "Слой", + "there": "там", + "Frame flights": "Показать самолёты в кадре", + "Frame military": "Показать военные самолёты в кадре", + "Frame vessels": "Показать суда в кадре", + "Fly to Location": "Перелёт к месту", + "Nearest aircraft to Here": "Ближайший самолёт здесь", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Оценка неполная: к моменту завершения сессии один из ответов ещё не был получен, поэтому его использование не учтено.", + "STANDARD": "СТАНДАРТ", + "vehicle motion|Stopped": "Стоит", + "ACQUIRING FRAME": "ПОЛУЧЕНИЕ КАДРА", + "Camera source temporarily unavailable": "Источник камер временно недоступен", + "Perimeter source unavailable": "Источник контуров пожаров недоступен", + "Imagery catalog unavailable": "Каталог снимков недоступен", + "Earthquake source unavailable": "Источник данных о землетрясениях недоступен", + "TeleGeography load failed": "Не удалось загрузить данные TeleGeography", + "Wind request timed out": "Истекло время ожидания запроса ветра", + "Malformed wind manifest": "Некорректный манифест ветра", + "Malformed wind grid": "Некорректная сетка ветра", + "Unknown wind model": "Неизвестная модель ветра", + "Unknown weather overlay": "Неизвестный погодный слой", + "Temperature field unavailable": "Поле температуры недоступно", + "Mean sea level pressure field unavailable": "Поле давления на уровне моря недоступно", + "KartaView answered HTTP {status}": "KartaView ответил: HTTP {status}", + "KartaView refused the request (code {code})": "KartaView отклонил запрос (код {code})", + "Stopped by voice": "Остановлено голосовой командой", + "drawings on the map": "рисунки на карте", + "imported map files": "импортированные файлы на карте", + "the active voice session": "активная голосовая сессия", + "Light aircraft": "Лёгкий самолёт", + "Glider": "Планёр", + "Turboprop": "Турбовинтовой самолёт", + "Airliner": "Авиалайнер", + "Widebody": "Широкофюзеляжный самолёт", + "Four-engine jet": "Четырёхмоторный реактивный самолёт", + "Helicopter": "Вертолёт", + "Fast jet": "Высокоманёвренный самолёт", + "Business jet": "Бизнес-джет", + "Drone": "Беспилотник", + "fire cause|Human": "деятельность человека", + "fire cause|Natural": "природная", + "fire cause|Undetermined": "не установлена", + "fire cause|Unknown": "неизвестна", + "fire behavior|Minimal": "минимальная активность", + "fire behavior|Moderate": "умеренная активность", + "fire behavior|Active": "высокая активность", + "fire behavior|Extreme": "экстремальная активность", + "Type {level} Incident": "инцидент {level}-го типа", + "${amount}K": "{amount} тыс. $", + "${amount}M": "{amount} млн $", + "${amount}B": "{amount} млрд $", + "{speed} kn": "{speed} уз", + "1090 MHz": "1090 МГц", + "978 MHz UAT": "978 МГц UAT", + "feed status|live": "онлайн", + "feed status|stale": "устарел", + "feed status|unreachable": "недоступен", + "feed status|invalid": "некорректный", + "Vector tile metadata unavailable": "Метаданные векторных тайлов недоступны", + "Vector tiles unavailable": "Векторные тайлы недоступны", + "Vector tiles unavailable (HTTP {status})": "Векторные тайлы недоступны (HTTP {status})", + "Vector tile request timed out": "Векторные тайлы: истекло время ожидания", + "Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы", + "Camera coverage unavailable": "Покрытие камер недоступно", + "Hybrid": "Гибрид", + "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)", + "OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Сначала дороги TomTom; остальное дополняет OpenStreetMap с симуляцией потока (нужен ключ TomTom)", + "Austin": "Остин", + "San Francisco": "Сан-Франциско", + "New York": "Нью-Йорк", + "Tokyo": "Токио", + "London": "Лондон", + "Paris": "Париж", + "Dubai": "Дубай", + "Washington DC": "Вашингтон", + "Tallinn": "Таллин", + "Los Angeles": "Лос-Анджелес", + "Data attribution": "Источники данных", + "Data provided by:": "Данные предоставлены:", + "Exit cockpit to view weather coverage": "Выйдите из кабины, чтобы показать зону покрытия погодного слоя", + "Exit cockpit to fly to a cyclone": "Выйдите из кабины, чтобы перелететь к циклону", + "the title of scene {scene} is empty or longer than {max} characters": "название сцены {scene} пустое или длиннее {max} символов", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "название кадра {shot} в сцене {scene} пустое или длиннее {max} символов", + "the project is larger than 5 MiB": "проект больше 5 МиБ", + "the project has too many shots": "в проекте слишком много кадров", + "the scene data failed validation": "данные сцены не прошли проверку", + "Layer on": "Слой: вкл.", + "Layer off": "Слой: выкл.", + "Zoom in": "Приблизить", + "Zoom out": "Отдалить", + "Zoom out to globe": "Отдалить до глобуса", + "Open data layers": "Открыть слои данных", + "Open or close a panel": "Открыть или закрыть панель", + "Change context mode": "Сменить режим контекста", + "Control cockpit": "Управлять кабиной", + "Change visual style": "Сменить визуальный стиль", + "Adjust HUD": "Настроить HUD", + "Adjust cyber sonar": "Настроить кибер-сонар", + "Adjust detection overlay": "Настроить слой детекции", + "Change map stack": "Сменить набор карт", + "Adjust post-processing": "Настроить постобработку", + "Control scene": "Управлять сценой", + "Control CCTV": "Управлять камерами CCTV", + "Control radio": "Управлять радио", + "Track contact": "Отслеживать контакт", + "Stop tracking": "Остановить отслеживание", + "Clear marks": "Убрать отметки", + "Move camera": "Переместить камеру", + "Fly route": "Пролететь по маршруту", + "Analyst query": "Аналитический запрос", + "Next ISS pass": "Следующий пролёт МКС", + "Next satellite pass": "Следующий пролёт спутника", + "Action": "Действие" } diff --git a/src/i18n/plural-english.test.mjs b/src/i18n/plural-english.test.mjs new file mode 100644 index 000000000..1a2a67f38 --- /dev/null +++ b/src/i18n/plural-english.test.mjs @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + checkCatalog, + extractMessages, + missingTemplate, +} from '../../scripts/i18n.mjs'; + +// A plural entry whose every form is still the English text must be reported +// the same way a string entry is ("is still English"). Today the plural +// branch of checkCatalog never compares forms with the English. +const fixture = new Map([ + ['{count} cameras', { plural: true, one: '{count} camera' }], + ['Save keys', { plural: false }], +]); + +test('string entry left in English is reported (control)', () => { + const { errors } = checkCatalog( + 'de', + { + 'Save keys': 'Save keys', + '{count} cameras': { one: 'x {count}', other: 'y {count}' }, + }, + fixture, + new Set(), + ); + assert.equal(errors.length, 1); + assert.match(errors[0], /is still English/); +}); + +for (const [locale, entry] of [ + ['de', { one: '{count} camera', other: '{count} cameras' }], + ['ja', { other: '{count} cameras' }], + ['zh-CN', { other: '{count} cameras' }], + [ + 'es', + { + one: '{count} camera', + many: '{count} cámaras', + other: '{count} cameras', + }, + ], + [ + 'ru', + { + one: '{count} camera', + few: '{count} камеры', + many: '{count} камер', + other: '{count} cameras', + }, + ], +]) { + test(`${locale}: plural entry with English forms is reported`, () => { + const { errors } = checkCatalog( + locale, + { 'Save keys': 'Schlüssel', '{count} cameras': entry }, + fixture, + new Set(), + ); + assert.ok( + errors.some((e) => /is still English/.test(e)), + `expected an "is still English" error, got ${JSON.stringify(errors)}`, + ); + }); +} + +// `npm run i18n -- --missing ` prints a template; pasted without edits, +// none of its entries may pass as a translation (K-21, K-52), and a plural +// entry carries every form the language needs (K-97). +const messages = extractMessages(); +for (const locale of ['de', 'ja', 'es', 'ru']) { + test(`${locale}: an untouched --missing template fails the check entry by entry`, () => { + const template = missingTemplate(locale, {}, messages); + assert.equal(Object.keys(template).length, messages.size); + const categories = new Intl.PluralRules(locale).resolvedOptions() + .pluralCategories; + for (const [text, info] of messages) + if (info.plural) + assert.deepEqual(Object.keys(template[text]), categories, text); + const { missing, errors } = checkCatalog( + locale, + template, + messages, + new Set(), + ); + assert.deepEqual(missing, []); + const accepted = [...messages.keys()].filter( + (text) => !errors.some((error) => error.startsWith(`"${text}" `)), + ); + assert.deepEqual(accepted, []); + }); +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5abe9ddc8..c92cfc1fb 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1,5 +1,7 @@ { "es": [ + "1090 MHz", + "978 MHz UAT", "AIS: --", "AIS: {name}", "ANIME", @@ -7,6 +9,7 @@ "AUTO", "Ambient", "Anime", + "Austin", "BRG {value}°", "BRG —", "Blues", @@ -79,6 +82,7 @@ "SRC {value}", "SSE", "STD", + "San Francisco", "Sector", "Sonar", "Soul", @@ -103,6 +107,7 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{speed} kn", "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/H", diff --git a/src/i18n/stale-index.test.mjs b/src/i18n/stale-index.test.mjs new file mode 100644 index 000000000..3690610ca --- /dev/null +++ b/src/i18n/stale-index.test.mjs @@ -0,0 +1,51 @@ +// K-23: scripts/i18n.mjs must skip index entries that are gone from the +// working tree (plain `mv`/`rm` without `git mv`/`git rm`). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + copyFileSync, + renameSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function sandbox() { + const root = mkdtempSync(path.join(tmpdir(), 'k23-')); + mkdirSync(path.join(root, 'scripts')); + mkdirSync(path.join(root, 'src/i18n/locales'), { recursive: true }); + copyFileSync( + path.join(HERE, '../../scripts/i18n.mjs'), + path.join(root, 'scripts/i18n.mjs'), + ); + writeFileSync(path.join(root, 'src/a.js'), "export const x = t('Hello');\n"); + writeFileSync(path.join(root, 'index.html'), '

Hello

\n'); + const git = (...args) => + execFileSync('git', args, { cwd: root, encoding: 'utf8' }); + git('init', '-q'); + git('add', '-A'); + git('-c', 'user.email=k@23', '-c', 'user.name=k23', 'commit', '-qm', 'init'); + return root; +} + +test('extractMessages survives a module renamed with plain mv', async () => { + const root = sandbox(); + try { + renameSync(path.join(root, 'src/a.js'), path.join(root, 'src/b.js')); + const mod = await import( + pathToFileURL(path.join(root, 'scripts/i18n.mjs')).href + ); + const messages = mod.extractMessages(); + assert.ok(messages.has('Hello')); + assert.doesNotThrow(() => mod.findUnmarked()); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/keySetup.js b/src/keySetup.js index 8ba48d9ca..dd43492d6 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -54,6 +54,74 @@ export function collectKeyUpdates(fields) { return updates; } +/** + * Why /api/setup/keys refused a save, by the `code` the server sends with its + * English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js). The + * English here is the server's own sentence, so the default locale reads the + * same as before. + */ +const SAVE_REFUSALS = Object.freeze({ + proxied: msg('Provider Settings does not answer proxied requests'), + sharing: msg('Provider Settings is disabled while sharing is enabled'), + 'not-loopback': msg( + 'Provider Settings answers only the machine running the server', + ), + 'not-local-host': msg('Provider Settings answers only local hostnames'), + 'origin-required': msg('Provider Settings requires an exact local Origin'), + 'origin-unrecognized': msg('Unrecognized Origin refused'), + 'cross-origin': msg('Cross-origin requests are refused'), + 'content-type': msg('Content-Type must be application/json'), + method: msg('Method not allowed'), + 'too-large': msg('Request too large'), + 'invalid-json': msg('Invalid JSON'), + 'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'), + 'no-keys': msg('No keys provided'), + 'too-many': msg('At most {limit} keys per save'), + 'unknown-key': msg('Unknown key: {name}'), + 'not-string': msg('{name} must be a string'), + empty: msg('{name} is empty'), + 'too-long': msg('{name} is longer than any real key ({limit} max)'), + 'not-printable': msg( + '{name} may only contain printable characters with no spaces', + ), + 'invalid-char': msg( + '{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)', + ), + external: msg( + '{name} is configured outside Provider Settings and can only be changed where it was set', + ), + 'harden-failed': msg( + 'The key was not saved: could not restrict the credential file to your account; nothing was saved', + ), + 'store-unreadable': msg( + 'The key was not saved: the existing configuration could not be read, so nothing was changed', + ), + 'write-failed': msg('Could not write the {store} store'), +}); + +/** + * The status line for a refused save — pure, exported for tests. A known + * `code` reads in the active language; anything else shows the server's text. + * @param {{code?: string, params?: object, error?: string}} payload + * @param {number} status HTTP status of the response. + * @returns {string} + */ +export function saveRefusalText(payload, status) { + const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code) + ? SAVE_REFUSALS[payload.code] + : null; + if (pattern) { + const values = {}; + for (const name of ['name', 'limit', 'store']) { + const value = payload.params?.[name]; + if (typeof value === 'string' || typeof value === 'number') + values[name] = value; + } + return t(pattern, values); + } + return payload?.error || t('Save failed ({status}).', { status }); +} + function sleepWithSignal(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -72,6 +140,36 @@ function sleepWithSignal(ms, signal) { }); } +/** + * An OAuth check error. A server-sent reason is kept as-is and later wrapped + * in «OAuth check failed: …»; when there is none, the error carries our own + * already-translated sentence and is flagged so the toast shows it unwrapped + * (the wrapper would only repeat it). + * @param {unknown} serverError + * @param {string} fallback Already-translated fallback sentence. + * @returns {Error} + */ +function oauthCheckError(serverError, fallback) { + if (serverError) return new Error(String(serverError)); + const error = new Error(fallback); + error.oauthFallback = true; + return error; +} + +/** + * Status text for a failed OAuth check — pure, exported for tests. A fallback + * sentence of our own already says what failed and is shown alone; any other + * reason (server text, network error) gets the «OAuth check failed» wrapper. + * @param {unknown} error + * @returns {string} + */ +export function oauthCheckFailureText(error) { + if (error?.oauthFallback && error.message) return error.message; + return t('OAuth check failed: {detail}', { + detail: error?.message || error, + }); +} + export async function waitForChatGptOAuth({ fetchImpl, signal, @@ -90,8 +188,9 @@ export async function waitForChatGptOAuth({ if (signal?.aborted) return false; if (response.ok && payload.available) return true; if (!response.ok || payload.loginFailed) { - throw new Error( - payload.error || 'Could not check ChatGPT sign-in. Try again.', + throw oauthCheckError( + payload.error, + t('Could not check ChatGPT sign-in. Try again.'), ); } if (now() >= deadline) break; @@ -415,11 +514,7 @@ export async function initKeySetup({ const payload = await response.json().catch(() => ({})); if (disposed) return; if (!response.ok || !payload.ok) { - // payload.error is a server-authored message (API value) — shown as-is. - say( - payload.error || - t('Save failed ({status}).', { status: response.status }), - ); + say(saveRefusalText(payload, response.status)); return; } for (const input of root.querySelectorAll('input[data-env-var]')) @@ -496,12 +591,12 @@ export async function initKeySetup({ if (current === 'oauth') { writeStoredCloudVoiceAuthMode('api-key'); syncCloudVoiceAuth(); - say('Cloud voice will use OPENAI_API_KEY on the next session.'); + say(t('Cloud voice will use OPENAI_API_KEY on the next session.')); return; } oauthBusy = true; syncCloudVoiceAuth(); - say('Checking local ChatGPT OAuth sign-in…'); + say(t('Checking local ChatGPT OAuth sign-in…')); try { const response = await doFetch('/api/realtime/oauth-status', { cache: 'no-store', @@ -510,22 +605,25 @@ export async function initKeySetup({ const payload = await response.json().catch(() => ({})); if (lifetime.signal.aborted) return; if (!response.ok) - throw new Error( - payload.error || 'Could not check ChatGPT sign-in.', + throw oauthCheckError( + payload.error, + t('Could not check ChatGPT sign-in.'), ); if (payload.available) { writeStoredCloudVoiceAuthMode('oauth'); syncCloudVoiceAuth(); say( - 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + t( + 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + ), ); return; } say( payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED' - ? 'ChatGPT sign-in expired. Opening sign-in again…' - : 'Opening ChatGPT sign-in in your browser…', + ? t('ChatGPT sign-in expired. Opening sign-in again…') + : t('Opening ChatGPT sign-in in your browser…'), ); const loginResponse = await doFetch('/api/realtime/oauth-login', { method: 'POST', @@ -538,7 +636,7 @@ export async function initKeySetup({ say( loginPayload.error || payload.error || - 'Could not start ChatGPT sign-in on this machine.', + t('Could not start ChatGPT sign-in on this machine.'), ); return; } @@ -546,13 +644,17 @@ export async function initKeySetup({ writeStoredCloudVoiceAuthMode('oauth'); syncCloudVoiceAuth(); say( - 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + t( + 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + ), ); return; } say( - 'Finish ChatGPT sign-in in the browser. Waiting for it to complete…', + t( + 'Finish ChatGPT sign-in in the browser. Waiting for it to complete…', + ), ); const available = await waitForChatGptOAuth({ fetchImpl: doFetch, @@ -561,18 +663,22 @@ export async function initKeySetup({ if (lifetime.signal.aborted) return; if (!available) { say( - 'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.', + t('ChatGPT sign-in timed out. Click {button} to try again.', { + button: t('USE CHATGPT OAUTH'), + }), ); return; } writeStoredCloudVoiceAuthMode('oauth'); syncCloudVoiceAuth(); say( - 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', + t( + 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', + ), ); } catch (error) { if (lifetime.signal.aborted) return; - say(`OAuth check failed: ${error?.message || error}`); + say(oauthCheckFailureText(error)); } finally { oauthBusy = false; if (!disposed) syncCloudVoiceAuth(); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index b565c7e97..9f99d4ed9 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -241,7 +241,9 @@ export function isSharingEnabled(env = {}) { * preflight this server never answers, closing the simple-request CSRF * write primitive. * - * @returns {{ok: true} | {ok: false, status: number, error: string}} + * A refusal also carries `code`, which the panel turns into its own localized + * sentence (keySetup.js); `error` stays the English for logs and API callers. + * @returns {{ok: true} | {ok: false, status: number, error: string, code: string}} */ export function admitKeySetupRequest({ method, @@ -262,6 +264,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Provider Settings does not answer proxied requests', + code: 'proxied', }; } // Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs) @@ -279,6 +282,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Provider Settings is disabled while sharing is enabled', + code: 'sharing', }; } if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) { @@ -286,6 +290,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Provider Settings answers only the machine running the server', + code: 'not-loopback', }; } const authority = localAuthority(hostHeader, protocol); @@ -294,6 +299,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Provider Settings answers only local hostnames', + code: 'not-local-host', }; } if ( @@ -304,6 +310,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Provider Settings requires an exact local Origin', + code: 'origin-required', }; } if (origin !== undefined && origin !== null && origin !== '') { @@ -311,7 +318,12 @@ export function admitKeySetupRequest({ try { parsedOrigin = new URL(String(origin)); } catch { - return { ok: false, status: 403, error: 'Unrecognized Origin refused' }; + return { + ok: false, + status: 403, + error: 'Unrecognized Origin refused', + code: 'origin-unrecognized', + }; } const exactOrigin = parsedOrigin.username === '' && @@ -325,6 +337,7 @@ export function admitKeySetupRequest({ ok: false, status: 403, error: 'Cross-origin requests are refused', + code: 'cross-origin', }; } } @@ -338,6 +351,7 @@ export function admitKeySetupRequest({ ok: false, status: 415, error: 'Content-Type must be application/json', + code: 'content-type', }; } return { ok: true }; @@ -447,6 +461,8 @@ export function keySetupStatus(env = {}) { * below safe to write without quoting rules. A `null` value means REMOVE: * the writer comments the assignment back out, returning the file to its * template state for that key. + * A refusal carries `code` and `params` for the panel's localized sentence + * (keySetup.js) besides the English `error`. * @param {unknown} body Parsed JSON from the request. */ export function validateKeySetupUpdates(body) { @@ -454,38 +470,63 @@ export function validateKeySetupUpdates(body) { return { ok: false, error: 'Body must be a JSON object of {ENV_VAR: value}', + code: 'not-object', }; } const entries = Object.entries(body); - if (entries.length === 0) return { ok: false, error: 'No keys provided' }; + if (entries.length === 0) + return { ok: false, error: 'No keys provided', code: 'no-keys' }; if (entries.length > KEY_SETUP_UPDATE_LIMIT) { return { ok: false, error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`, + code: 'too-many', + params: { limit: KEY_SETUP_UPDATE_LIMIT }, }; } const known = knownKeySetupEnvVars(); const updates = {}; for (const [name, raw] of entries) { - if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` }; + if (!known.has(name)) + return { + ok: false, + error: `Unknown key: ${name}`, + code: 'unknown-key', + params: { name }, + }; if (raw === null) { updates[name] = null; continue; } if (typeof raw !== 'string') - return { ok: false, error: `${name} must be a string` }; + return { + ok: false, + error: `${name} must be a string`, + code: 'not-string', + params: { name }, + }; const value = raw.trim(); - if (!value) return { ok: false, error: `${name} is empty` }; + if (!value) + return { + ok: false, + error: `${name} is empty`, + code: 'empty', + params: { name }, + }; if (value.length > KEY_SETUP_VALUE_LIMIT) { return { ok: false, error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`, + code: 'too-long', + params: { name, limit: KEY_SETUP_VALUE_LIMIT }, }; } if (!/^[\x21-\x7e]+$/.test(value)) { return { ok: false, error: `${name} may only contain printable characters with no spaces`, + code: 'not-printable', + params: { name }, }; } // Reject the dotenv metacharacters that would round-trip WRONG when written @@ -497,6 +538,8 @@ export function validateKeySetupUpdates(body) { return { ok: false, error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`, + code: 'invalid-char', + params: { name }, }; } updates[name] = value; diff --git a/src/keySetupErrorLocale.test.mjs b/src/keySetupErrorLocale.test.mjs new file mode 100644 index 000000000..570bf2ac4 --- /dev/null +++ b/src/keySetupErrorLocale.test.mjs @@ -0,0 +1,134 @@ +// K-25: a refused key save reads in the panel's language. The server sends a +// `code` (and `params`) beside its English `error`; the panel builds the +// sentence through t(), so English stays the server's own text and es/ru get +// the catalog's. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + admitKeySetupRequest, + KEY_SETUP_UPDATE_LIMIT, + validateKeySetupUpdates, +} from './keySetupCore.mjs'; +import { collectKeyUpdates, saveRefusalText } from './keySetup.js'; +import { setLocale } from './i18n/index.js'; +import { extractMessages } from '../scripts/i18n.mjs'; + +const local = { + method: 'POST', + remoteAddress: '127.0.0.1', + hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', + contentType: 'application/json', +}; + +// Every refusal the core can return, plus the ones only the endpoint sends +// (server/standalone/key-setup.js), with the English the server writes. +const refusals = () => [ + validateKeySetupUpdates( + collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: '"sk-abc123"' }]), + ), + validateKeySetupUpdates( + collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: 'sk abc123' }]), + ), + validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(600) }), + validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }), + validateKeySetupUpdates({ OPENAI_API_KEY: 42 }), + validateKeySetupUpdates({ PATH: '/usr/bin' }), + validateKeySetupUpdates({}), + validateKeySetupUpdates([]), + validateKeySetupUpdates( + Object.fromEntries( + Array.from({ length: KEY_SETUP_UPDATE_LIMIT + 1 }, (_, i) => [ + `K${i}`, + 'v', + ]), + ), + ), + admitKeySetupRequest({ ...local, proxyHeaders: { 'x-forwarded-for': '1' } }), + admitKeySetupRequest({ + ...local, + env: { PINOKIO_SHARE_CLOUDFLARE: 'true' }, + }), + admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }), + admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }), + admitKeySetupRequest({ ...local, origin: undefined }), + admitKeySetupRequest({ ...local, origin: 'not a url' }), + admitKeySetupRequest({ ...local, origin: 'https://evil.example' }), + admitKeySetupRequest({ ...local, contentType: 'text/plain' }), + { error: 'Method not allowed', code: 'method' }, + { error: 'Request too large', code: 'too-large' }, + { error: 'Invalid JSON', code: 'invalid-json' }, + { + error: + 'OPENAI_API_KEY is configured outside Provider Settings and can only be changed where it was set', + code: 'external', + params: { name: 'OPENAI_API_KEY' }, + }, + { + error: + 'The key was not saved: could not restrict the credential file to your account; nothing was saved', + code: 'harden-failed', + }, + { + error: + 'The key was not saved: the existing configuration could not be read, so nothing was changed', + code: 'store-unreadable', + }, + { + error: 'Could not write the env-file store', + code: 'write-failed', + params: { store: 'env-file' }, + }, +]; + +test('every refusal carries a code', () => { + for (const refusal of refusals()) { + assert.equal(refusal.ok ?? false, false); + assert.equal(typeof refusal.code, 'string', refusal.error); + } +}); + +test('in English the panel shows exactly the server sentence', () => { + setLocale('en'); + for (const refusal of refusals()) + assert.equal(saveRefusalText(refusal, 400), refusal.error); +}); + +test('an unknown code or a bare error falls back as before', () => { + setLocale('en'); + assert.equal( + saveRefusalText({ error: 'Something new', code: 'new' }, 400), + 'Something new', + ); + assert.equal(saveRefusalText({}, 502), 'Save failed (502).'); +}); + +test('the refusal sentences are catalog messages', () => { + const messages = extractMessages(); + setLocale('en'); + for (const refusal of refusals()) { + const pattern = saveRefusalText( + { + code: refusal.code, + params: { name: '{name}', limit: '{limit}', store: '{store}' }, + }, + 400, + ); + assert.ok(messages.has(pattern), pattern); + } +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: a refused save reads in the panel language`, () => { + setLocale(locale); + try { + for (const refusal of refusals()) { + const text = saveRefusalText(refusal, 400); + assert.notEqual(text, refusal.error, `${locale}: ${refusal.error}`); + if (refusal.params?.name) assert.ok(text.includes(refusal.params.name)); + } + } finally { + setLocale('en'); + } + }); +} diff --git a/src/keySetupOAuthFailure.test.mjs b/src/keySetupOAuthFailure.test.mjs new file mode 100644 index 000000000..46d1ea586 --- /dev/null +++ b/src/keySetupOAuthFailure.test.mjs @@ -0,0 +1,55 @@ +// C9: a failed ChatGPT OAuth check whose only reason is our own fallback +// sentence shows that sentence alone — «OAuth check failed: Could not check +// ChatGPT sign-in. Try again.» repeated itself. A server-sent reason keeps the +// wrapper. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js'; +import { setLocale } from './i18n/index.js'; + +const failWith = (payload, ok = true) => + waitForChatGptOAuth({ + fetchImpl: async () => ({ ok, json: async () => payload }), + sleep: () => assert.fail('failed login must stop polling'), + }).then( + () => assert.fail('expected a rejection'), + (error) => error, + ); + +test('a fallback-only OAuth failure is shown without the wrapper in every locale', async (t) => { + t.after(() => setLocale('en')); + const expected = { + en: 'Could not check ChatGPT sign-in. Try again.', + ru: 'Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.', + es: 'No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.', + }; + for (const [locale, text] of Object.entries(expected)) { + setLocale(locale); + const loginFailed = await failWith({ available: false, loginFailed: true }); + assert.equal(oauthCheckFailureText(loginFailed), text); + const httpFailed = await failWith({}, false); + assert.equal(oauthCheckFailureText(httpFailed), text); + } +}); + +test('a server-sent or network OAuth failure keeps the «OAuth check failed» wrapper', async (t) => { + t.after(() => setLocale('en')); + setLocale('en'); + const server = await failWith({ + loginFailed: true, + error: 'Sign-in is local only.', + }); + assert.equal( + oauthCheckFailureText(server), + 'OAuth check failed: Sign-in is local only.', + ); + assert.equal( + oauthCheckFailureText(new TypeError('Failed to fetch')), + 'OAuth check failed: Failed to fetch', + ); + setLocale('ru'); + assert.match( + oauthCheckFailureText(server), + /^Не удалось проверить OAuth: Sign-in is local only\.$/, + ); +}); diff --git a/src/keySetupOAuthI18n.test.mjs b/src/keySetupOAuthI18n.test.mjs new file mode 100644 index 000000000..73ac30a3e --- /dev/null +++ b/src/keySetupOAuthI18n.test.mjs @@ -0,0 +1,66 @@ +// K-26: every status line of the ChatGPT OAuth flow in Provider Settings goes +// through t(), and the i18n check treats say() as a place UI text lands. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + extractMessages, + readCatalog, + shippedLocales, + unmarkedScript, +} from '../scripts/i18n.mjs'; + +// Status-line texts of the ChatGPT OAuth flow in Provider Settings +// (src/keySetup.js, handler of [data-cloud-voice-auth-toggle]). +const OAUTH_STATUS_TEXTS = [ + 'Cloud voice will use OPENAI_API_KEY on the next session.', + 'Checking local ChatGPT OAuth sign-in…', + 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + 'ChatGPT sign-in expired. Opening sign-in again…', + 'Opening ChatGPT sign-in in your browser…', + 'Finish ChatGPT sign-in in the browser. Waiting for it to complete…', + 'ChatGPT sign-in timed out. Click {button} to try again.', + 'OAuth check failed: {detail}', + 'Could not check ChatGPT sign-in.', + 'Could not check ChatGPT sign-in. Try again.', + 'Could not start ChatGPT sign-in on this machine.', + 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', +]; + +test('K-26: OAuth status-line texts are extractable messages with es/ru entries', () => { + const messages = extractMessages(); + const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text)); + assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js'); + for (const locale of shippedLocales()) { + const catalog = readCatalog(locale); + const untranslated = OAUTH_STATUS_TEXTS.filter((text) => !catalog[text]); + assert.deepEqual(untranslated, [], `${locale}.json lacks entries`); + } +}); + +test('K-26: the i18n check sees an unmarked literal passed to say()', () => { + const sample = + "const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n"; + const found = unmarkedScript('sample.js', sample); + assert.ok( + found.some((f) => f.text.includes('Checking local ChatGPT OAuth')), + 'say(...) is not among JS_SINKS in scripts/i18n.mjs', + ); +}); + +test('K-26: src/keySetup.js has no unmarked say() literals once say is a sink', () => { + const source = readFileSync( + new URL('./keySetup.js', import.meta.url), + 'utf8', + ); + const literal = /\bsay\(\s*['"`]/g; + const hits = [...source.matchAll(literal)].map( + (m) => source.slice(0, m.index).split('\n').length, + ); + assert.deepEqual( + hits, + [], + `say() called with a bare literal at lines ${hits.join(', ')}`, + ); +}); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index bd7c45c4e..1a0ca4c73 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -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 { diff --git a/src/layers/alpr/source.js b/src/layers/alpr/source.js index cfc8dd2d4..1b886fe9e 100644 --- a/src/layers/alpr/source.js +++ b/src/layers/alpr/source.js @@ -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 || diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index 732ec9c90..64a4a3b96 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -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'); } }), ); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index e4c03a690..92c7803b6 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -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 { diff --git a/src/layers/cyclones/coverage.test.mjs b/src/layers/cyclones/coverage.test.mjs new file mode 100644 index 000000000..9043ea746 --- /dev/null +++ b/src/layers/cyclones/coverage.test.mjs @@ -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(); +}); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 0763aab17..67d1288fc 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -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.', ), diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index c2cdabada..da23094c9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -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; diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index f4f353653..2766f83d4 100644 --- a/src/layers/directions/source.js +++ b/src/layers/directions/source.js @@ -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; }, }; diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index f4fd12bd8..0d522fcb9 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -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; diff --git a/src/layers/feedDegradationI18n.test.mjs b/src/layers/feedDegradationI18n.test.mjs new file mode 100644 index 000000000..b7bda49f4 --- /dev/null +++ b/src/layers/feedDegradationI18n.test.mjs @@ -0,0 +1,85 @@ +// K-34: AIS degradation chip text and the traffic "SIMULATED — " 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 — " 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', + ); +}); diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index e363fe72b..494ab5f1e 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -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; diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js index 5ab2fc95c..8b40b48d7 100644 --- a/src/layers/flights/ingestion.js +++ b/src/layers/flights/ingestion.js @@ -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); } diff --git a/src/layers/installations/classLabel.js b/src/layers/installations/classLabel.js index f3a14fe45..af7f1bc5d 100644 --- a/src/layers/installations/classLabel.js +++ b/src/layers/installations/classLabel.js @@ -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': diff --git a/src/layers/installations/classLabel.packClasses.test.mjs b/src/layers/installations/classLabel.packClasses.test.mjs new file mode 100644 index 000000000..6319e0f52 --- /dev/null +++ b/src/layers/installations/classLabel.packClasses.test.mjs @@ -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(', ')}`, + ); + }); +} diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index ff12a88ce..9f8c0075f 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -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), diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 57cda2710..664d81943 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -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); diff --git a/src/layers/installations/installationFeedbackLocale.test.mjs b/src/layers/installations/installationFeedbackLocale.test.mjs new file mode 100644 index 000000000..cf4e758a3 --- /dev/null +++ b/src/layers/installations/installationFeedbackLocale.test.mjs @@ -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', + ); +}); diff --git a/src/layers/installations/namedMarkers.js b/src/layers/installations/namedMarkers.js index d66fd9c3b..48a314ac2 100644 --- a/src/layers/installations/namedMarkers.js +++ b/src/layers/installations/namedMarkers.js @@ -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); diff --git a/src/layers/installations/rendering.js b/src/layers/installations/rendering.js index 24801d82f..43489e3f2 100644 --- a/src/layers/installations/rendering.js +++ b/src/layers/installations/rendering.js @@ -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), diff --git a/src/layers/installations/unnamedInstallationTitle.test.mjs b/src/layers/installations/unnamedInstallationTitle.test.mjs new file mode 100644 index 000000000..ba47337e8 --- /dev/null +++ b/src/layers/installations/unnamedInstallationTitle.test.mjs @@ -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'); + } +}); diff --git a/src/layers/localAdsb/card.i18n.test.mjs b/src/layers/localAdsb/card.i18n.test.mjs new file mode 100644 index 000000000..b1ff42f4a --- /dev/null +++ b/src/layers/localAdsb/card.i18n.test.mjs @@ -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'); + } +}); diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index c656bd9fe..8a9baeb80 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -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, diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index 6d5c523e2..db9afbbf2 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -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'; diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js index ee3dbc2d1..9777b1831 100644 --- a/src/layers/military/ingestion.js +++ b/src/layers/military/ingestion.js @@ -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); } diff --git a/src/layers/perimeters/cards.i18n.test.mjs b/src/layers/perimeters/cards.i18n.test.mjs new file mode 100644 index 000000000..e6ca86f5a --- /dev/null +++ b/src/layers/perimeters/cards.i18n.test.mjs @@ -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'); +}); diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index 6609d0a73..7ef2017a1 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -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), diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 70898ba37..0ef9a9bcc 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -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; diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 8a89b444c..869b8d403 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -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 { diff --git a/src/layers/radio/ingestionErrors.i18n.test.mjs b/src/layers/radio/ingestionErrors.i18n.test.mjs new file mode 100644 index 000000000..1717e13ed --- /dev/null +++ b/src/layers/radio/ingestionErrors.i18n.test.mjs @@ -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'); + } + }); +} diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js index cbb4b8be5..9cd22a917 100644 --- a/src/layers/radio/playback.js +++ b/src/layers/radio/playback.js @@ -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; } diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 969a6a8de..6dd6e34a1 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -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; diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 161bdd004..406dc3e19 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -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))); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..7e1a7ba6d 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -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', ); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 2986d5344..2b2624ff7 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -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; diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index a81891dc5..6e8b9e2d7 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -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; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index f50bcd380..d52570c94 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -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); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 84f9e2fc7..58a962e4c 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -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', +); diff --git a/src/layers/streetLevel/providers/google/mapsLoader.js b/src/layers/streetLevel/providers/google/mapsLoader.js index 841120347..3e2f4c4a4 100644 --- a/src/layers/streetLevel/providers/google/mapsLoader.js +++ b/src/layers/streetLevel/providers/google/mapsLoader.js @@ -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); }); diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js index 1c7fdfa34..422f52c0c 100644 --- a/src/layers/streetLevel/providers/google/viewer.js +++ b/src/layers/streetLevel/providers/google/viewer.js @@ -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 = () => { diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index ae05983a4..4f339c421 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -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, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 52dd38274..a0946a731 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -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'; diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index ddf21817f..dada2ba65 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -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; }, diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index 175ccc702..92be74757 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -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 diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index c55c0d26e..4689f3688 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -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. diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 3a562c21e..89e9ad843 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -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(); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index 7f32570d5..5468996d5 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -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, diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 44fde3fdd..d33e5a076 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -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}`, diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 71c8a3442..7135fd8c6 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -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'); } /** diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index d19c8258f..291c06c90 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -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. diff --git a/src/layers/traffic/roadStatus.i18n.test.mjs b/src/layers/traffic/roadStatus.i18n.test.mjs new file mode 100644 index 000000000..dd72e21f8 --- /dev/null +++ b/src/layers/traffic/roadStatus.i18n.test.mjs @@ -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'); + } + }); +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index 7dc923a99..02478759e 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -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, diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index b672581bb..4d6b18aea 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -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. */ diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index cd7d33968..fd066ca55 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -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 diff --git a/src/layers/transit/selectionCopy.i18n.test.mjs b/src/layers/transit/selectionCopy.i18n.test.mjs new file mode 100644 index 000000000..7b0059a06 --- /dev/null +++ b/src/layers/transit/selectionCopy.i18n.test.mjs @@ -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'); + } + }); +} diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 9d5b2af87..da0a2ce82 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -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(); } /** diff --git a/src/layers/vessels/detectionLabel.test.mjs b/src/layers/vessels/detectionLabel.test.mjs new file mode 100644 index 000000000..2707f3a18 --- /dev/null +++ b/src/layers/vessels/detectionLabel.test.mjs @@ -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'); +}); diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 408e3bec2..dca9b81ca 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -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 { diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 4393cdf79..ffbe45c72 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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 = []; diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index b84c1026e..75e5cdd67 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -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'), }; /** diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 9df8a6042..d677cc453 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 2d3c7d276..b595f3925 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -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; diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index d0b8afb18..186f263c1 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -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} 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, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 2bff63168..16f876cb0 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -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; diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index ccbb8e991..0957f9ce4 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -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; diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index cd9786123..b25bcceb2 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -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); }), ); diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index 65bcd4a51..cf004476f 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -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 { diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 885c811f0..b72cf4b09 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -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.', ), diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 038365408..233a19799 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -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), diff --git a/src/layers/wind/staleInfo.test.mjs b/src/layers/wind/staleInfo.test.mjs new file mode 100644 index 000000000..dbe997feb --- /dev/null +++ b/src/layers/wind/staleInfo.test.mjs @@ -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(); + } +}); diff --git a/src/layers/wind/windProblemLocale.test.mjs b/src/layers/wind/windProblemLocale.test.mjs new file mode 100644 index 000000000..a5fba5395 --- /dev/null +++ b/src/layers/wind/windProblemLocale.test.mjs @@ -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'); + } + }); +} diff --git a/src/layers/wind/windUpstreamReason.test.mjs b/src/layers/wind/windUpstreamReason.test.mjs new file mode 100644 index 000000000..72eb19a17 --- /dev/null +++ b/src/layers/wind/windUpstreamReason.test.mjs @@ -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'); + } + }); +} diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index a3d836e4a..4513787b8 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -7,8 +7,35 @@ * flying. */ +import { t, msg } from './i18n/index.js'; + export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken'; +// The Cockpit refusal per destination noun. Nouns are English identifiers +// from the callers ('camera', 'location', 'photo'…); a noun without its own +// sentence falls back to the template with the noun as given. +const COCKPIT_REFUSALS = Object.freeze({ + camera: msg('Exit cockpit to fly to a camera'), + location: msg('Exit cockpit to fly to a location'), + photo: msg('Exit cockpit to fly to a photo'), + 'shared view': msg('Exit cockpit to fly to a shared view'), + target: msg('Exit cockpit to fly to a target'), + vessel: msg('Exit cockpit to fly to a vessel'), + fire: msg('Exit cockpit to fly to a fire'), + route: msg('Exit cockpit to fly to a route'), + file: msg('Exit cockpit to fly to a file'), + scene: msg('Exit cockpit to fly to a scene'), + // Radar, satellite and lightning fly to the layer's coverage area (their + // «View coverage» button); the cyclone layer flies to the selected storm. + weather: msg('Exit cockpit to view weather coverage'), + cyclone: msg('Exit cockpit to fly to a cyclone'), +}); + +function cockpitRefusal(noun) { + const ready = COCKPIT_REFUSALS[noun]; + return ready ? t(ready) : t('Exit cockpit to fly to a {noun}', { noun }); +} + /** * Announce that a layer-owned camera flight is taking navigation authority. * @@ -90,7 +117,7 @@ export function runExplicitNavigation({ } = {}) { if (disposed) return false; if (cockpitActive) { - showToast?.(`Exit cockpit to fly to a ${noun}`); + showToast?.(cockpitRefusal(noun)); return false; } const generation = stamp?.(); @@ -112,7 +139,7 @@ export function beginDeferredNavigation({ } = {}) { if (disposed) return false; if (cockpitActive) { - showToast?.(`Exit cockpit to fly to a ${noun}`); + showToast?.(cockpitRefusal(noun)); return false; } return stamp?.(); @@ -133,7 +160,7 @@ export function reassertNavigationHandoff({ } = {}) { if (disposed || generation !== currentGeneration) return false; if (cockpitActive) { - showToast?.('Exit cockpit to fly to a location'); + showToast?.(cockpitRefusal('location')); return false; } release?.(); diff --git a/src/navigationPolicy.test.mjs b/src/navigationPolicy.test.mjs index 7ee19e8a4..1cdacc011 100644 --- a/src/navigationPolicy.test.mjs +++ b/src/navigationPolicy.test.mjs @@ -132,6 +132,19 @@ test('cockpit refuses without stamping or releasing anything', () => { } }); +test('weather layers name their destination in the Cockpit refusal', () => { + // «Exit cockpit to fly to a weather» was the template with a bare noun. + const expected = { + weather: 'Exit cockpit to view weather coverage', + cyclone: 'Exit cockpit to fly to a cyclone', + }; + for (const [noun, text] of Object.entries(expected)) { + const s = spy(); + runExplicitNavigation({ cockpitActive: true, noun, ...s }); + assert.deepEqual(s.log, [`toast:${text}`]); + } +}); + test('disposed navigation is inert before any camera or UI mutation', () => { const s = spy(); const result = runExplicitNavigation({ disposed: true, cockpitActive: true, ...s }); diff --git a/src/scenes/director.js b/src/scenes/director.js index 18ac1d501..6379b9192 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -28,7 +28,7 @@ import { getSceneAppendRecipeById, } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; -import { t, tn } from '../i18n/index.js'; +import { t, tn, msg } from '../i18n/index.js'; import { createSceneDataPacks } from './dataPacks/controller.js'; import { createSceneInteractions } from './interactions.js'; import { createSceneSharing } from './sharing.js'; @@ -63,6 +63,7 @@ import { stringifySceneDocument, SceneDocumentError, } from '../director/document.js'; +import { sceneSaveFailureReason } from './sceneSaveError.js'; // Built-in recipe DISPLAY names. Recipe ids, the URL 'scene' param, stored // project titles, and voice-matching titles all stay English (keep-English @@ -131,7 +132,7 @@ export class SceneDirector { ) return; if (this._usesAuthoredCamera && !this._claimingCamera) - this.stopScene('Camera ownership changed'); + this.stopScene(msg('Camera ownership changed')); }; this._cameraHandoffUnsubscribe = styleManager.subscribeCameraHandoff?.(yieldCamera); @@ -180,7 +181,9 @@ export class SceneDirector { this._presentation = { status: this._storageReadError - ? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.' + ? t( + 'Saved project could not be read; storage preserved. Import a valid file to resume saving.', + ) : t('Ready'), progress: 0, runtime: '', @@ -219,7 +222,7 @@ export class SceneDirector { return; const scene = this._getSelectedScene(); if (!scene?.releaseLayerIds?.includes(change.layerId)) return; - this.stopScene('Scene layer turned off'); + this.stopScene(msg('Scene layer turned off')); }); } @@ -247,7 +250,7 @@ export class SceneDirector { this._controls?.destroy(); this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { - this.stopScene('Stopped'); + this.stopScene(msg('Stopped')); this._loadAbort?.abort(); this._loadGeneration++; this.viewer.camera.cancelFlight(); @@ -323,7 +326,9 @@ export class SceneDirector { _saveProject() { if (this._storageReadError) { this._toastStorageError( - 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', + t( + 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', + ), ); return; } @@ -336,13 +341,20 @@ export class SceneDirector { // Private browsing / block-all-cookies / quota-exceeded throws here. The // in-memory project stays usable this session, but persistence failed — // tell the user instead of crashing the caller (M11). + // A validation failure is logged with its technical path and check; + // the toast says it in words (sceneSaveError.js). + const invalid = e instanceof SceneDocumentError; console.warn( - '[Scenes] Could not persist project (storage unavailable):', + invalid + ? '[Scenes] Could not persist project (invalid project data):' + : '[Scenes] Could not persist project (storage unavailable):', e, ); this._toastStorageError( - e instanceof SceneDocumentError - ? `Scene not saved — ${e.message}` + invalid + ? t('Scene not saved — {reason}', { + reason: sceneSaveFailureReason(e), + }) : undefined, ); } @@ -350,7 +362,7 @@ export class SceneDirector { /** Surface a "scene not saved" notice via the global toast + scene status line. */ _toastStorageError( - message = 'Scene not saved — browser storage unavailable', + message = t('Scene not saved — browser storage unavailable'), ) { this._updateStatus(message); try { @@ -381,7 +393,7 @@ export class SceneDirector { if (type === 'shot-loaded') this._presentation.status = t('Loaded: {scene} / {shot}', { scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }); this._publish({ type, @@ -409,7 +421,7 @@ export class SceneDirector { title: displaySceneTitle(scene), shots: scene.shots.map((shot) => ({ ...shot, - displayTitle: displayShotTitle(shot.title), + displayTitle: displayShotTitle(shot.title, shot, scene), })), })), selectedSceneId: this._selectedSceneId, @@ -984,7 +996,7 @@ export class SceneDirector { this._updateStatus( t('Captured: {scene} / {shot}', { scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }), ); } @@ -1017,7 +1029,7 @@ export class SceneDirector { this._updateStatus( t('Updated: {scene} / {shot}', { scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }), ); } @@ -1182,7 +1194,7 @@ export class SceneDirector { this._updateStatus( t('Seeked: {scene} / {shot}', { scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }), ); this._activateInteractions(scene, shot); @@ -1549,7 +1561,7 @@ export class SceneDirector { this._updateStatus( t('Seeked: {scene} / {shot}', { scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }), ); return true; @@ -1558,7 +1570,7 @@ export class SceneDirector { /** Seek the complete authored scene state to an exact clock position. */ seekScene(sceneId, progress) { if (this._destroyed) return Promise.resolve(false); - this.stopScene('Seeking scene clock'); + this.stopScene(msg('Seeking scene clock')); const generation = ++this._sceneSeekGeneration; return this._trackWork(this._seekScene(sceneId, progress, generation)); } @@ -1895,9 +1907,11 @@ export class SceneDirector { * enabled while the post-await check skips its params. The manager rolls an * aborted enable back through the module's own disable(). * - * @param {string} [reason='Stopped'] - Human-readable cancellation reason + * @param {string} [reason='Stopped'] - Cancellation reason, in English: it + * is logged as data and translated only for the status line (callers mark + * theirs with msg()). */ - stopScene(reason = 'Stopped') { + stopScene(reason = msg('Stopped')) { this._setSceneMediaPlayback(); this._interactions?.clear(); this._dataPacks?.clear(); @@ -1912,7 +1926,7 @@ export class SceneDirector { if (!this._running || !this._runToken) return; this._runToken.cancelled = true; this._runAbort?.abort(); - this._updateStatus(reason); + this._updateStatus(t(reason)); this._logEvent('scene_stopped', { reason }); } @@ -2055,7 +2069,7 @@ export class SceneDirector { ) return false; // Validate before touching playback, selection or the saved project. - this.stopScene('Importing project'); + this.stopScene(msg('Importing project')); await Promise.allSettled([...(this._pendingWork || [])]); if (this._destroyed || generation !== this._importGeneration) return; if ( @@ -2316,7 +2330,7 @@ export class SceneDirector { token, ); if (!completed && !token.cancelled && !this._destroyed) - this.stopScene('Camera move interrupted'); + this.stopScene(msg('Camera move interrupted')); } /** @@ -2405,7 +2419,7 @@ export class SceneDirector { const pending = readers.filter(({ read }) => read()?.pending === true); if (!pending.length) return; if (pending.some(({ maxWaitMs }) => Date.now() - began >= maxWaitMs)) { - this.stopScene('Scene media timed out'); + this.stopScene(msg('Scene media timed out')); throw new Error( 'Scene media did not finish within its bounded playback window', ); diff --git a/src/scenes/directorStorageNotices.test.mjs b/src/scenes/directorStorageNotices.test.mjs new file mode 100644 index 000000000..8e652537d --- /dev/null +++ b/src/scenes/directorStorageNotices.test.mjs @@ -0,0 +1,43 @@ +// Director storage-failure notices and stop reasons must be marked for +// translation and present in every shipped catalog, so es/ru users do not +// get English toasts or Scene status lines. The stop reason itself stays +// English data (it is logged); only the status line translates it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + extractMessages, + readCatalog, + shippedLocales, +} from '../../scripts/i18n.mjs'; + +const NOTICES = [ + 'Scene not saved — browser storage unavailable', + 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', + 'Saved project could not be read; storage preserved. Import a valid file to resume saving.', + 'Scene not saved — {reason}', + 'Stopped', + 'Stopped (Esc)', + 'Stopped by voice', + 'Camera ownership changed', + 'Scene layer turned off', + 'Seeking scene clock', + 'Importing project', + 'Camera move interrupted', + 'Scene media timed out', +]; + +const messages = extractMessages(); + +for (const text of NOTICES) { + test(`Director notice is marked with t()/msg(): ${text}`, () => { + assert.ok(messages.has(text), `not marked for translation: ${text}`); + }); + for (const locale of shippedLocales()) { + test(`${locale} catalog translates: ${text}`, () => { + const value = readCatalog(locale)[text]; + assert.ok(value, `missing in ${locale}.json`); + assert.notEqual(value, text); + }); + } +} diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index c1d82bddc..e44286988 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -21,7 +21,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) { index: index + 1, total, scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title), + shot: displayShotTitle(shot.title, shot, scene), }), ); director._logEvent('shot_start', { diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 29e3572ed..c996a329c 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -1047,19 +1047,40 @@ export function displaySceneTitle(scene) { const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id); if (builtin?.title && scene.title === builtin.title) return t(builtin.title); const title = scene?.title || ''; - const numbered = /^Scene (\d+)$/.exec(title); - return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : title; + // Only the generated "Scene N" localizes; an operator-typed "Scene 007" + // keeps its digits, so the number stays a string (no Number() round trip). + const numbered = /^Scene ([1-9]\d*)$/.exec(title); + return numbered ? t('Scene {n}', { n: numbered[1] }) : title; } /** * Display title for a shot: built-in titles and the numbered "Shot N" * fallback localize; any other title shows as stored. + * + * With the shot itself, a built-in title localizes only when the shot came + * from a recipe (`sourcePackId`), so an operator-typed title that happens to + * match a built-in one is never masked by the catalog. Without it, provenance + * is unknown and the title alone decides, as before. * @param {string} title + * @param {{sourcePackId?: string}} [shot] + * @param {{id?: string}} [scene] The shot's scene, for projects saved before + * shots recorded their source pack. * @returns {string} */ -export function displayShotTitle(title) { +export function displayShotTitle(title, shot = undefined, scene = undefined) { const text = typeof title === 'string' ? title : ''; - if (BUILTIN_SHOT_TITLES.has(text)) return t(text); - const numbered = /^Shot (\d+)$/.exec(text); - return numbered ? t('Shot {n}', { n: Number(numbered[1]) }) : text; + const fromRecipe = shot + ? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text) + : true; + if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text); + const numbered = /^Shot ([1-9]\d*)$/.exec(text); + return numbered ? t('Shot {n}', { n: numbered[1] }) : text; +} + +/** Whether the built-in recipe behind a scene declares a shot with this title. */ +function recipeDeclaresShotTitle(sceneId, title) { + const recipe = sceneId ? getSceneRecipeById(sceneId) : null; + return Boolean( + recipe?.cameraPath?.some((keyframe) => keyframe?.title === title), + ); } diff --git a/src/scenes/sceneSaveError.js b/src/scenes/sceneSaveError.js new file mode 100644 index 000000000..d69ebb06d --- /dev/null +++ b/src/scenes/sceneSaveError.js @@ -0,0 +1,56 @@ +/** + * The reason shown in «Scene not saved — {reason}» when the project fails its + * own validation on save. + * + * A SceneDocumentError names a JSON path and an English check + * («$.scenes[6].title: expected nonempty text, at most 4096 characters») — + * right for the console and for import diagnostics, meaningless in a toast. + * The failures an author can cause from the panel get a plain sentence with + * 1-based scene and shot numbers. Anything else keeps the technical text in + * English and gets a general sentence in a translation; director.js logs the + * error either way. + */ + +import { formatNumber, getLocale, t } from '../i18n/index.js'; + +const TEXT_LIMIT = /^expected nonempty text, at most (\d+) characters$/; +const SCENE_TITLE = /^\$\.scenes\[(\d+)\]\.title$/; +const SHOT_TITLE = /^\$\.scenes\[(\d+)\]\.shots\[(\d+)\]\.title$/; + +const ordinal = (index) => formatNumber(Number(index) + 1); + +/** + * @param {unknown} error Usually a SceneDocumentError ({path, reason}). + * @returns {string} Reason in the UI language. + */ +export function sceneSaveFailureReason(error) { + const path = typeof error?.path === 'string' ? error.path : ''; + const reason = + typeof error?.reason === 'string' + ? error.reason + : String(error?.message || '').slice(path ? path.length + 2 : 0); + const limit = TEXT_LIMIT.exec(reason); + if (limit) { + const max = formatNumber(Number(limit[1])); + const scene = SCENE_TITLE.exec(path); + if (scene) + return t( + 'the title of scene {scene} is empty or longer than {max} characters', + { scene: ordinal(scene[1]), max }, + ); + const shot = SHOT_TITLE.exec(path); + if (shot) + return t( + 'the title of shot {shot} in scene {scene} is empty or longer than {max} characters', + { scene: ordinal(shot[1]), shot: ordinal(shot[2]), max }, + ); + } + if (path === '$' && reason === 'file exceeds 5 MiB') + return t('the project is larger than 5 MiB'); + if (reason === 'too many shots in project') + return t('the project has too many shots'); + // English keeps the validator's own text, which names the field; another + // language gets a general sentence (the field is in the console warning). + if (getLocale() === 'en' && error?.message) return String(error.message); + return t('the scene data failed validation'); +} diff --git a/src/scenes/sceneSaveError.test.mjs b/src/scenes/sceneSaveError.test.mjs new file mode 100644 index 000000000..eaa7c92fa --- /dev/null +++ b/src/scenes/sceneSaveError.test.mjs @@ -0,0 +1,102 @@ +// Proposal 8: «Scene not saved — {reason}» showed the validator's JSON path +// and English check. Known failures read as a sentence; the rest get a +// general reason (the technical text is logged by director.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { setLocale } from '../i18n/index.js'; +import { + SceneDocumentError, + validateSceneDocument, +} from '../director/document.js'; +import { sceneSaveFailureReason } from './sceneSaveError.js'; + +const caught = (project) => { + try { + validateSceneDocument(project); + } catch (error) { + return error; + } + return assert.fail('expected a validation error'); +}; + +test('an empty scene title is named with its 1-based scene number', () => { + setLocale('en'); + const scenes = Array.from({ length: 7 }, (_, i) => ({ + id: `s${i}`, + title: i === 6 ? ' ' : `Scene ${i + 1}`, + shots: [], + })); + const error = caught({ version: 6, scenes }); + assert.ok(error instanceof SceneDocumentError); + assert.equal(error.path, '$.scenes[6].title'); + assert.equal( + sceneSaveFailureReason(error), + 'the title of scene 7 is empty or longer than 4,096 characters', + ); +}); + +test('a shot title names the shot and its scene', () => { + setLocale('en'); + const error = caught({ + version: 6, + scenes: [ + { + id: 'a', + title: 'A', + shots: [{ id: 'x' }, { id: 'y', title: 'z'.repeat(4097) }], + }, + ], + }); + assert.equal( + sceneSaveFailureReason(error), + 'the title of shot 2 in scene 1 is empty or longer than 4,096 characters', + ); +}); + +test('size and shot-count limits read as sentences', () => { + setLocale('en'); + assert.equal( + sceneSaveFailureReason(new SceneDocumentError('$', 'file exceeds 5 MiB')), + 'the project is larger than 5 MiB', + ); + assert.equal( + sceneSaveFailureReason( + new SceneDocumentError('$.scenes[3].shots', 'too many shots in project'), + ), + 'the project has too many shots', + ); +}); + +test('any other validation failure: English keeps the field, a translation never shows the path', () => { + const errors = [ + new SceneDocumentError( + '$.scenes[0].shots[2].layers.x.enabled', + 'expected a boolean', + ), + new SceneDocumentError('$.scenes[1].anchors[0].id', 'duplicate ID'), + new Error('$.scenes[0]: odd'), + ]; + setLocale('en'); + for (const error of errors) + assert.equal(sceneSaveFailureReason(error), error.message); + assert.equal( + sceneSaveFailureReason(null), + 'the scene data failed validation', + ); + for (const locale of ['ru', 'es']) { + setLocale(locale); + try { + for (const error of [...errors, null]) { + const text = sceneSaveFailureReason(error); + assert.doesNotMatch( + text, + /\$\.|expected|duplicate|odd/, + `${locale}: ${text}`, + ); + } + } finally { + setLocale('en'); + } + } +}); diff --git a/src/scenes/sceneTitleDisplay.test.mjs b/src/scenes/sceneTitleDisplay.test.mjs new file mode 100644 index 000000000..3c6043aac --- /dev/null +++ b/src/scenes/sceneTitleDisplay.test.mjs @@ -0,0 +1,143 @@ +// Display helpers must not rewrite operator-typed scene/shot titles: only the +// generated "Scene N" / "Shot N" and the titles of recipe-sourced shots +// localize; everything else shows exactly as stored. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + SCENE_RECIPES, + displaySceneTitle, + displayShotTitle, +} from './recipes.js'; +import { setLocale } from '../i18n/index.js'; + +test.afterEach(() => setLocale('en')); + +test('user-typed numbered titles keep their digits in en', () => { + setLocale('en'); + assert.equal( + displaySceneTitle({ id: 'scene-user', title: 'Scene 007' }), + 'Scene 007', + ); + assert.equal(displayShotTitle('Shot 01'), 'Shot 01'); + // default generated names still work + assert.equal( + displaySceneTitle({ id: 'scene-user', title: 'Scene 3' }), + 'Scene 3', + ); + assert.equal(displayShotTitle('Shot 2'), 'Shot 2'); +}); + +test('generated numbered titles localize, typed ones with leading zeros do not', () => { + setLocale('ru'); + assert.equal( + displaySceneTitle({ id: 'scene-user', title: 'Scene 3' }), + 'Сцена 3', + ); + assert.equal(displayShotTitle('Shot 2'), 'Кадр 2'); + assert.equal( + displaySceneTitle({ id: 'scene-user', title: 'Scene 007' }), + 'Scene 007', + ); + assert.equal(displayShotTitle('Shot 01'), 'Shot 01'); +}); + +test('a built-in shot title localizes only for a recipe-sourced shot', () => { + setLocale('ru'); + // A built-in shot title (BUILTIN_SHOT_TITLES in recipes.js). + const title = 'Final view'; + const userScene = { id: 'scene-user' }; + // Operator-typed title that happens to match a built-in one: shown as typed. + assert.equal( + displayShotTitle(title, { id: 'shot-user', title }, userScene), + title, + ); + // A recipe-sourced shot: localized. + const fromRecipe = displayShotTitle( + title, + { id: 'shot-recipe', title, sourcePackId: 'bhote-koshi-nepal-scene' }, + userScene, + ); + assert.notEqual(fromRecipe, title); + // Without provenance the title alone decides, as before. + assert.equal(displayShotTitle(title), fromRecipe); + // A project saved before shots recorded their source pack: the scene's + // recipe still vouches for a title its keyframes declare (only when such a + // recipe ships in this build). + const named = (keyframe) => + keyframe?.title && !/^Shot \d+$/.test(keyframe.title); + const recipe = SCENE_RECIPES.find((item) => item.cameraPath?.some(named)); + if (recipe) { + const declared = recipe.cameraPath.find(named).title; + assert.equal( + displayShotTitle( + declared, + { id: 'shot-old', title: declared }, + { id: recipe.id }, + ), + displayShotTitle(declared), + ); + assert.equal( + displayShotTitle( + declared, + { id: 'shot-old', title: declared }, + userScene, + ), + declared, + ); + } +}); + +test('the Scene status line shows an operator-typed shot title as typed', async () => { + const { createScenePlaybackAdapter } = await import('./playbackAdapter.js'); + const statuses = []; + const director = { + _renderSceneSelect() {}, + _renderShotList() {}, + _updateStatus: (text) => statuses.push(text), + _logEvent() {}, + }; + const adapter = createScenePlaybackAdapter(director, 4); + const title = 'Final view'; + const scene = { id: 'scene-user', title: 'Scene 1', shots: [] }; + setLocale('ru'); + adapter.selectShot({ + scene, + shot: { id: 'shot-user', title }, + index: 0, + total: 1, + }); + adapter.selectShot({ + scene, + shot: { id: 'shot-recipe', title, sourcePackId: 'bhote-koshi-nepal-scene' }, + index: 0, + total: 1, + }); + assert.ok(statuses[0].endsWith(` / ${title}`), statuses[0]); + assert.ok( + statuses[1].endsWith(` / ${displayShotTitle(title)}`) && + !statuses[1].endsWith(` / ${title}`), + statuses[1], + ); +}); + +test('the scene context of a shot carries its display title by provenance', async () => { + const { layerStatesForShot } = await import('./shotPresentation.js'); + const title = 'Final view'; + const packs = { recipeForShot: () => null, minimumHoldSec: () => 0 }; + const contextFor = (shot) => { + const scene = { id: 'scene-user', title: 'Scene 1', shots: [shot] }; + const states = layerStatesForShot( + scene, + { ...shot, layers: { a: { params: { presentation: 'scene-beat' } } } }, + packs, + ); + return states.a.params.sceneContext; + }; + setLocale('ru'); + assert.equal(contextFor({ id: 'u', title }).shotDisplayTitle, title); + assert.notEqual( + contextFor({ id: 'r', title, sourcePackId: 'bhote-koshi-nepal-scene' }) + .shotDisplayTitle, + title, + ); +}); diff --git a/src/scenes/shotPresentation.js b/src/scenes/shotPresentation.js index f98953d0e..04759fc3f 100644 --- a/src/scenes/shotPresentation.js +++ b/src/scenes/shotPresentation.js @@ -68,7 +68,7 @@ export function layerStatesForShot( sceneDisplayTitle: displaySceneTitle(scene), shotId: shot.id, shotTitle: shot.title, - shotDisplayTitle: displayShotTitle(shot.title), + shotDisplayTitle: displayShotTitle(shot.title, shot, scene), shotIndex, shotCount: scene.shots.length, durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC, diff --git a/src/sdr/controller.js b/src/sdr/controller.js index b84205dcb..0dbca7219 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -17,7 +17,7 @@ import { selectAuthorizedSdrDevice, } from './usbDevices.js'; import { summarizeLocalAdsb } from '../sources/adsbRecords.js'; -import { t } from '../i18n/index.js'; +import { formatNumber, t } from '../i18n/index.js'; export { createRememberingWebUsb } from './usbDevices.js'; @@ -1170,7 +1170,7 @@ export class SdrController { ? {} : { message: t('Tuned to {mhz} MHz FM', { - mhz: (frequency / 1_000_000).toFixed(1), + mhz: formatMhz(frequency), }), }), }); @@ -1206,7 +1206,7 @@ export class SdrController { const token = ++this._seekToken; this._setState({ seeking: true, - seekMessage: `Seeking ${seekDirection > 0 ? 'up' : 'down'}…`, + seekMessage: seekDirection > 0 ? t('Seeking up…') : t('Seeking down…'), }); this._audioNode?.port.postMessage({ type: 'volume', value: 0 }); try { @@ -1217,7 +1217,7 @@ export class SdrController { const sequence = this._spectrumSequence; await this.tuneFm(center, { quiet: true }); this._setState({ - seekMessage: `Scanning ${(center / 1_000_000).toFixed(1)} MHz…`, + seekMessage: t('Scanning {mhz} MHz…', { mhz: formatMhz(center) }), }); if ( !(await this._waitForSpectrum(sequence)) || @@ -1235,9 +1235,12 @@ export class SdrController { await this.tuneFm(peak.frequency, { quiet: true }); this._setState({ seeking: false, - seekMessage: `Found ${(peak.frequency / 1_000_000).toFixed(1)} MHz · ${peak.snr.toFixed(1)} dB`, + seekMessage: t('Found {mhz} MHz · {snr} dB', { + mhz: formatMhz(peak.frequency), + snr: formatOneDecimal(peak.snr), + }), message: t('FM signal found at {mhz} MHz', { - mhz: (peak.frequency / 1_000_000).toFixed(1), + mhz: formatMhz(peak.frequency), }), }); return peak; @@ -1371,6 +1374,19 @@ export class SdrController { } } +/** One decimal in the UI language ("98.5" / "98,5"); status text only. */ +function formatOneDecimal(value) { + return formatNumber(value, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + useGrouping: false, + }); +} + +function formatMhz(hz) { + return formatOneDecimal(hz / 1_000_000); +} + function positiveModulo(value, divisor) { return ((value % divisor) + divisor) % divisor; } diff --git a/src/sdr/sdrSeekLocalized.test.mjs b/src/sdr/sdrSeekLocalized.test.mjs new file mode 100644 index 000000000..634180cdd --- /dev/null +++ b/src/sdr/sdrSeekLocalized.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { SdrController } from './controller.js'; +import { localSdrCardView } from '../ui/localSdrPresentation.js'; + +function makeController() { + const c = new SdrController({ storage: null, webUsb: null }); + c._device = {}; + c.state.mode = 'fm'; + const seen = []; + c.subscribe?.((s) => seen.push(localSdrCardView(s))); + c.tuneFm = async (f) => { + c.state.frequencyHz = f; + const sr = c.state.sampleRate; + const bins = new Float32Array(1024).fill(-80); + const target = 98_800_000; + const idx = Math.round((target - f) / (sr / 1024) + 512); + for (let i = idx - 20; i <= idx + 20; i++) + if (i >= 0 && i < 1024) bins[i] = -10; + c._spectrum = bins; + return true; + }; + c._waitForSpectrum = async () => true; + c.state.frequencyHz = 98_500_000; + return { c, seen }; +} + +for (const locale of ['es', 'ru']) { + test(`FM seek messages are localized in ${locale}`, async () => { + setLocale(locale); + try { + const { c, seen } = makeController(); + const peak = await c.seekFm(1); + assert.ok(peak, 'synthetic peak found'); + const texts = seen.map((view) => view.statusText); + // Every status line shown during and after the seek is in the UI language. + for (const text of texts) { + assert.doesNotMatch(text, /Seeking (up|down)|Scanning \d|Found \d/); + } + // Decimal separator follows the locale (98,8 in es and ru). + assert.match(texts.at(-1), /98,8/); + } finally { + setLocale('en'); + } + }); +} + +test('FM seek messages keep their English wording and decimal point', async () => { + setLocale('en'); + const { c, seen } = makeController(); + const peak = await c.seekFm(1); + assert.ok(peak, 'synthetic peak found'); + const texts = seen.map((view) => view.statusText); + assert.ok(texts.includes('Seeking up…')); + assert.ok(texts.includes('Scanning 98.5 MHz…')); + assert.match(texts.at(-1), /^Found 98\.8 MHz · \d+\.\d dB$/); +}); diff --git a/src/sharelink.js b/src/sharelink.js index e2a9e41b2..6072fb1bd 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -511,6 +511,8 @@ export class ShareLinkManager { if (!params) return false; params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); const copiedUrl = new URL(window.location.href); + // ?lang is the sender's language override; the recipient keeps their own. + copiedUrl.searchParams.delete('lang'); copiedUrl.hash = params.toString(); try { await navigator.clipboard.writeText(copiedUrl.href); diff --git a/src/sharelink.lang.test.mjs b/src/sharelink.lang.test.mjs new file mode 100644 index 000000000..7ffa1b388 --- /dev/null +++ b/src/sharelink.lang.test.mjs @@ -0,0 +1,77 @@ +// K-22: a copied share link must not carry the sender's ?lang override: on the +// recipient's side ?lang would beat their own stored choice. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { resolveLocale } from './i18n/locale.js'; +import { persistLocaleAndReload } from './i18n/index.js'; + +function makeManager(href) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +test('K-22: the copied link does not carry the sender ?lang override', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + assert.equal(await manager.copyLink({ nowMs: 1_000_000 }), true); + const link = new URL(copied); + assert.equal(link.searchParams.get('lang'), null, `copied link: ${copied}`); + + // Why it matters: on the recipient's side ?lang beats the stored choice and + // the browser language. + const store = new Map([['gev:locale:v1', 'es']]); + const recipient = resolveLocale({ + location: { search: link.search }, + storage: { getItem: (k) => store.get(k) ?? null }, + languages: ['en-US'], + }); + assert.equal(recipient, 'es'); +}); + +test('K-22: with storage blocked the dock switch keeps the choice in ?lang', () => { + let href = 'https://gev.example/#v=2'; + const location = { + get href() { + return href; + }, + assign(next) { + href = next; + }, + reload() {}, + }; + const history = { + replaceState: (_s, _t, url) => { + href = url; + }, + }; + const blocked = { + setItem() { + throw new Error('blocked'); + }, + }; + persistLocaleAndReload('ru', { location, history, storage: blocked }); + // Documented behaviour (persistLocaleAndReload): the choice survives only in + // the URL, which is why copyLink has to drop it. + assert.equal(new URL(href).searchParams.get('lang'), 'ru'); +}); diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js index 2415ff43c..cd2b1162e 100644 --- a/src/sources/live/contract.js +++ b/src/sources/live/contract.js @@ -9,6 +9,9 @@ * Snapshots describe coverage and completeness independently of freshness. * Records contain observation data only, with no scene objects or transport data. */ +// Messages are English data (AI tools read them too); the Layers panel shows +// them translated by their shape (translateFeedError in src/ui/layerPanel.js), +// so a new or reworded message is changed there as well. export class LiveSourceError extends Error { constructor( code, diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 58208a1fd..274c2ace7 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -3,6 +3,11 @@ import { VectorTile } from '@mapbox/vector-tile'; import { createVectorTileSource } from './vectorTiles.js'; import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js'; import { tileToBBox } from '../data/tomtomTiles.js'; +// Kept English in data; the map card and Contacts translate it. +import { UNNAMED_MILITARY_AREA } from '../data/installationNames.js'; + +/** Name of a military polygon the names pack has not named. */ +export { UNNAMED_MILITARY_AREA }; const ROAD_TYPES = Object.freeze({ motorway: 'motorway', @@ -191,7 +196,7 @@ function decodeLayers(bytes, z, x, y, layerNames) { id: `ofm:${z}/${x}/${y}:${feature.id ?? i}:${p}`, kind: 'installation', class: 'military_land', - name: 'Military area', + name: UNNAMED_MILITARY_AREA, latitude, longitude, footprint: ring, diff --git a/src/sources/vectorTiles.js b/src/sources/vectorTiles.js index 6a90f4aaf..c09b0d1db 100644 --- a/src/sources/vectorTiles.js +++ b/src/sources/vectorTiles.js @@ -2,6 +2,11 @@ import { phaseTiming } from './phaseTiming.js'; import { tilesForBounds } from '../data/tomtomTiles.js'; import { readResponseBytesCapped, readResponseJsonCapped } from './httpBody.js'; +// Marks text for i18n extraction; the UI translates these failures where it +// shows them (layer row, loading chip). Kept local because AI tool services +// import this module outside the browser. +const msg = (text) => text; + /** Validate a non-dateline geographic viewport before selecting a bounded grid. */ export function validTileBounds(box) { return ( @@ -70,7 +75,10 @@ export function createVectorTileSource({ const timer = setTimeout( () => controller.abort( - new DOMException('Vector tile request timed out', 'TimeoutError'), + new DOMException( + msg('Vector tile request timed out'), + 'TimeoutError', + ), ), 12_000, ); @@ -87,8 +95,9 @@ export function createVectorTileSource({ throw Object.assign( new Error( status === null - ? 'Vector tiles unavailable' - : `Vector tiles unavailable (HTTP ${status})`, + ? msg('Vector tiles unavailable') + : // Shown through the 'Vector tiles unavailable (HTTP {status})' key. + `Vector tiles unavailable (HTTP ${status})`, ), { status }, ); @@ -172,7 +181,9 @@ export function createVectorTileSource({ if (flight.controller.signal.aborted || error.name === 'AbortError') throw error; metadataError = Object.assign( - new Error('Vector tile metadata unavailable', { cause: error }), + new Error(msg('Vector tile metadata unavailable'), { + cause: error, + }), { status: error.status ?? null, name: error.name === 'TimeoutError' ? 'TimeoutError' : 'Error', @@ -364,10 +375,13 @@ export function createVectorTileSource({ const limited = candidates.length > maxTiles; // Refuse over-wide views instead of silently sampling a northwest strip. if (limited) - throw Object.assign(new Error('Zoom in for vector tile coverage'), { - retryable: false, - code: 'TILE_VIEW_TOO_WIDE', - }); + throw Object.assign( + new Error(msg('Zoom in for vector tile coverage')), + { + retryable: false, + code: 'TILE_VIEW_TOO_WIDE', + }, + ); let index = 0; const results = new Array(candidates.length); await Promise.all( diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ca352c93d..4af8a9d95 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -37,7 +37,14 @@ import { ShellFeedback } from './shellFeedback.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; -import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js'; +import { + describeUnsavedWork, + getLocale, + msg, + persistLocaleAndReload, + registerUnsavedWork, + t, +} from '../i18n/index.js'; import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js'; /** @@ -710,7 +717,8 @@ export class StyleManager extends ShellFacade { /** * Renders the command-dock locale switch: one button per shipped locale, * labelled with its code and named in its own language. A click stores the - * choice and reloads, so nothing re-renders live. + * choice and reloads, so nothing re-renders live; it asks first when the + * reload would lose work (see _confirmLocaleReload). * @returns {void} */ _initLocaleSelector() { @@ -718,6 +726,20 @@ export class StyleManager extends ShellFacade { '#control-panel .dock-locale-switch', ); if (!container) return; + // Work this shell can see that a reload drops. Other modules register + // their own (registerUnsavedWork in src/i18n/index.js). + this._unsavedWorkChecks = [ + registerUnsavedWork(() => + this.services?.cctvLayer + ?.getUIState?.() + ?.cameras?.some((camera) => camera.calDirty) + ? msg('unsaved CCTV calibration') + : null, + ), + registerUnsavedWork(() => + this.cockpitView?.active ? msg('the open cockpit') : null, + ), + ]; const active = getLocale(); for (const locale of LOCALES) { const button = document.createElement('button'); @@ -731,12 +753,29 @@ export class StyleManager extends ShellFacade { button.classList.toggle('active', isActive); button.setAttribute('aria-pressed', String(isActive)); button.addEventListener('click', () => { + if (!this._confirmLocaleReload()) return; persistLocaleAndReload(button.dataset.locale); }); container.appendChild(button); } } + /** + * Switching language reloads the page. While that would lose work the page + * cannot restore, ask first. + * @returns {boolean} true to switch. + */ + _confirmLocaleReload() { + const lost = describeUnsavedWork(); + if (!lost.length || typeof globalThis.confirm !== 'function') return true; + return globalThis.confirm( + t( + 'Switching language reloads the page. This will be lost: {items}. Switch anyway?', + { items: lost.join(', ') }, + ), + ); + } + /** * Gives every Display-panel row label one shared column, sized in * characters to the longest label of the active language, so the slider @@ -1718,6 +1757,8 @@ export class StyleManager extends ShellFacade { this._shareState.destroy(); this._locationNavigation.destroy(); this._lifetime.destroy(); + for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) + unregister(); this._recording.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 82a679258..0fb67f1d4 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -326,7 +326,7 @@ export function bindCameraOrientationControls({ : false; }); if (result) { - showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view'); + showToast?.(result.tilted ? t('Tilted view') : t('Straight-down view')); // The action reports the state it just commanded, so the button can // follow it without a second pick. tilted = result.tilted; @@ -345,7 +345,7 @@ export function bindCameraOrientationControls({ }) ); }); - if (result) showToast?.('North up'); + if (result) showToast?.(t('North up')); sync(); }); diff --git a/src/ui/contextLayerChanges.i18n.test.mjs b/src/ui/contextLayerChanges.i18n.test.mjs new file mode 100644 index 000000000..254a62eda --- /dev/null +++ b/src/ui/contextLayerChanges.i18n.test.mjs @@ -0,0 +1,119 @@ +// The blocked-visibility toast speaks the UI language: the Space Missions +// guard reason (English data from contextModePolicy.js) is re-composed with +// the layer name localized, a known lifecycle sentence is looked up, and an +// unknown provider message passes through. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { + contextLayerEnableBlockReason, + parseContextLayerBlockReason, +} from '../contextModePolicy.js'; +import { + _handleContextLayerChange, + localizeVisibilityBlockReason, +} from './contextLayerChanges.js'; + +function fakeShell() { + const toasts = []; + return { + destroyed: false, + _userFacingContextNotificationTokens: new Set(), + _contextSessionSnapshot: null, + _contextModeChanging: false, + _contextMode: 'space-missions', + _contextModeEntering: null, + _syncContextModeButtons() {}, + showToast(message) { + toasts.push(message); + }, + toasts, + }; +} + +const ENGLISH_GUARD = + 'Space Missions isolates replay data. Exit the mode to enable Live Flights.'; + +test.afterEach(() => setLocale('en')); + +test('the guard reason is English data and parses back to its layer name', () => { + const reason = contextLayerEnableBlockReason({ + contextMode: 'space-missions', + change: { layerId: 'flights', enabled: true, origin: 'user' }, + layerName: 'Live Flights', + }); + assert.equal(reason, ENGLISH_GUARD); + assert.deepEqual(parseContextLayerBlockReason(reason), { + layer: 'Live Flights', + }); + assert.equal(parseContextLayerBlockReason('Data source unavailable'), null); + assert.equal( + parseContextLayerBlockReason( + 'Space Missions isolates replay data. Exit the mode to enable .', + ), + null, + ); +}); + +test('en: the toast shows the reason unchanged', () => { + setLocale('en'); + const shell = fakeShell(); + _handleContextLayerChange.call(shell, { + type: 'visibility-blocked', + layerId: 'flights', + enabled: true, + origin: 'user', + reason: ENGLISH_GUARD, + }); + assert.deepEqual(shell.toasts, [ENGLISH_GUARD]); + assert.equal( + localizeVisibilityBlockReason('Overpass returned HTTP 503'), + 'Overpass returned HTTP 503', + ); +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: the Space Missions block toast is localized with the layer name`, () => { + setLocale(locale); + const reason = contextLayerEnableBlockReason({ + contextMode: 'space-missions', + change: { layerId: 'flights', enabled: true, origin: 'user' }, + layerName: 'Live Flights', + }); + const shell = fakeShell(); + _handleContextLayerChange.call(shell, { + type: 'visibility-blocked', + layerId: 'flights', + enabled: true, + origin: 'user', + reason, + }); + assert.equal(shell.toasts.length, 1); + assert.notEqual(shell.toasts[0], ENGLISH_GUARD, 'toast must be localized'); + assert.doesNotMatch(shell.toasts[0], /Space Missions isolates/); + assert.doesNotMatch(shell.toasts[0], /Live Flights/); + }); + + test(`${locale}: a known lifecycle reason is looked up in the catalog`, () => { + setLocale(locale); + const shell = fakeShell(); + shell._contextMode = null; + _handleContextLayerChange.call(shell, { + type: 'visibility-blocked', + layerId: 'street-level', + enabled: true, + origin: 'user', + reason: 'Data source unavailable', + }); + assert.equal(shell.toasts.length, 1); + assert.notEqual(shell.toasts[0], 'Data source unavailable'); + }); + + test(`${locale}: an unknown provider message passes through verbatim`, () => { + setLocale(locale); + assert.equal( + localizeVisibilityBlockReason('Overpass returned HTTP 503'), + 'Overpass returned HTTP 503', + ); + }); +} diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index 5e9dc125a..7c1965e90 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,12 +1,32 @@ -import { t } from '../i18n/index.js'; +import { t, translateKnown } from '../i18n/index.js'; import { + SPACE_MISSIONS_LAYER_BLOCK_REASON, cockpitEntryAllowed, isExplicitUserIntentOrigin, + parseContextLayerBlockReason, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition, } from '../contextModePolicy.js'; +import { localizeLayerName } from './layerNames.js'; + +/** + * A blocked-visibility reason in the UI language. The lifecycle keeps reasons + * English (they also reach voice and tool callers): the Space Missions guard + * is re-composed from its template with the layer name localized, any other + * known sentence ("Data source unavailable") is looked up as is, and an + * unknown one — a live provider message — passes through. + */ +export function localizeVisibilityBlockReason(reason) { + if (typeof reason !== 'string' || !reason) return reason; + const blocked = parseContextLayerBlockReason(reason); + if (blocked) + return t(SPACE_MISSIONS_LAYER_BLOCK_REASON, { + layer: localizeLayerName(blocked.layer), + }); + return translateKnown(reason); +} export function _handleContextLayerChange(change) { if (this.destroyed) return; @@ -95,7 +115,7 @@ export function _handleContextLayerChange(change) { !this._userFacingContextNotificationTokens.has(change.notificationToken) ) { this.showToast( - change.reason || + localizeVisibilityBlockReason(change.reason) || t('That layer is unavailable in the current Context mode'), ); } diff --git a/src/ui/cssContentCaption.test.mjs b/src/ui/cssContentCaption.test.mjs new file mode 100644 index 000000000..f5e556ac9 --- /dev/null +++ b/src/ui/cssContentCaption.test.mjs @@ -0,0 +1,34 @@ +// K-49: the CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the +// i18n scanner (scripts/i18n.mjs) never reads CSS, so es/ru show it in English. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const STYLES = path.join(HERE, 'styles'); +const LOCALES = path.join(HERE, '..', 'i18n', 'locales'); +// cyber.css carries decorative theme strings (LOC / GRID, SYS 06 // ...); +// street-level.css ' · ESC' is a key cap. Neither is UI copy. +const DECORATIVE = new Set(['cyber.css', 'street-level.css']); + +test('K-49: no user-visible text in CSS content: outside decorative themes', () => { + const hits = []; + for (const file of readdirSync(STYLES)) { + if (!file.endsWith('.css') || DECORATIVE.has(file)) continue; + const css = readFileSync(path.join(STYLES, file), 'utf8'); + css.split('\n').forEach((line, i) => { + if (/^\s*content:\s*['"][^'"]*[A-Za-z]{2,}/.test(line)) + hits.push(`${file}:${i + 1} ${line.trim()}`); + }); + } + assert.deepEqual(hits, []); +}); + +test('K-49: the CCTV acquisition caption has es and ru translations', () => { + for (const lang of ['es', 'ru']) { + const dict = JSON.parse(readFileSync(path.join(LOCALES, `${lang}.json`), 'utf8')); + assert.ok('ACQUIRING FRAME' in dict, `${lang}.json lacks "ACQUIRING FRAME"`); + } +}); diff --git a/src/ui/feedErrorI18n.test.mjs b/src/ui/feedErrorI18n.test.mjs new file mode 100644 index 000000000..0a2cc65fd --- /dev/null +++ b/src/ui/feedErrorI18n.test.mjs @@ -0,0 +1,69 @@ +// K-47: Flights / Military feed failure texts reach the DATA LAYERS row in the +// UI language; the feeds keep them in English. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerPanel } from './layerPanel.js'; +import { setLocale } from '../i18n/index.js'; +import { httpError, LiveSourceError } from '../sources/live/contract.js'; + +const panel = LayerPanel.prototype; + +function flightsRow(error) { + return panel._buildMetaText({ + id: 'flights', + enabled: true, + stats: { + count: 0, + stale: true, + error, + status: 429, + retryInSec: 45, + source: 'OpenSky', + lastUpdate: Date.now(), + }, + }); +} + +const cases = [ + ['OpenSky 429 anonymous', 'OpenSky rate limited (anonymous)'], + ['OpenSky auth', 'OpenSky auth required'], + ['generic 429', httpError({ status: 429 }, 'Flights').message], + ['HTTP 503', httpError({ status: 503 }, 'Military aircraft').message], + [ + 'network error', + new LiveSourceError('unavailable', 'Flights network error').message, + ], + ['stale snapshot', 'Source snapshot 7 min old'], + ['snapshot time unknown', 'Source snapshot time unavailable'], + ['non-LiveSourceError', 'Live data unavailable'], + ['malformed', 'Malformed OpenSky response'], + ['malformed rows', 'Malformed adsb.lol aircraft rows'], +]; + +test('en: feed failure texts read as before', () => { + setLocale('en'); + for (const [, message] of cases) + assert.equal( + flightsRow(message), + `STALE · OpenSky · ${message} · retry 45s`, + ); +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: feed failure texts are translated in the layer row`, () => { + setLocale(locale); + try { + for (const [name, message] of cases) { + const row = flightsRow(message); + assert.ok( + !row.includes(message), + `${locale} ${name}: row still contains English detail: ${row}`, + ); + } + assert.match(flightsRow('Source snapshot 7 min old'), /\b7\b/); + assert.match(flightsRow('Flights HTTP 503'), /\b503\b/); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index ef62a6355..8785f24fd 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -5,7 +5,14 @@ import { MAX_IMPORTED_FILES, loadGeoFile, } from '../data/geoFileImport.js'; -import { formatNumber, t, tn, translateKnown } from '../i18n/index.js'; +import { + formatNumber, + msg, + registerUnsavedWork, + t, + tn, + translateKnown, +} from '../i18n/index.js'; import { flyToDataSource } from './flyToDataSource.js'; // One color per file, in import order, from the whiteboard palette. @@ -44,6 +51,11 @@ export function initGeoFileImport({ let cameraIntent = 0; const lifetime = new AbortController(); let removeMapSource; + // Imports live for the page session: switching language (a reload) asks + // first while any are on the map. + const unregisterUnsavedWork = registerUnsavedWork(() => + !destroyed && imports.length ? msg('imported map files') : null, + ); const listen = (target, type, listener, options) => { if (!target?.addEventListener) return; @@ -428,6 +440,7 @@ export function initGeoFileImport({ destroy() { if (destroyed) return; destroyed = true; + unregisterUnsavedWork(); lifetime.abort(); removeMapSource?.(); clearTimeout(groundTimer); diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 510883cb1..5b31b6246 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { initGeoFileImport, summarize } from './geoFileImport.js'; import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js'; +import { describeUnsavedWork } from '../i18n/index.js'; class FakeClassList { constructor() { @@ -211,6 +212,20 @@ const rows = (els) => els['geo-import-list'].children; const action = (row, name) => row.children.find((c) => c.dataset.action === name); +// K-27. Before the other tests: the tools they leave behind would answer the +// same registry. +test('imported files make the language switch ask first', async () => { + const t = setup(); + assert.deepEqual(describeUnsavedWork(), []); + await t.tool.importFiles([file('a.geojson')]); + assert.deepEqual(describeUnsavedWork(), ['imported map files']); + t.tool.remove(t.tool.list()[0].id); + assert.deepEqual(describeUnsavedWork(), [], 'nothing left to lose'); + await t.tool.importFiles([file('b.kml')]); + t.tool.destroy(); + assert.deepEqual(describeUnsavedWork(), [], 'destroy unregisters'); +}); + test('returns null without its markup or viewer', () => { const document = { getElementById: () => null }; assert.equal(initGeoFileImport({ viewer: {}, document }), null); diff --git a/src/ui/imageryBoxTool.js b/src/ui/imageryBoxTool.js index 98b232f70..5bd58a414 100644 --- a/src/ui/imageryBoxTool.js +++ b/src/ui/imageryBoxTool.js @@ -13,6 +13,7 @@ import { releasePointer, } from '../data/inputOwnership.js'; import { validateBox } from '../layers/recentImagery/model.js'; +import { msg } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box'; @@ -150,7 +151,7 @@ export function initImageryBoxTool({ if (!active || destroyed || anchor) return; const point = worldAt(event.position); if (!point) { - onCancel?.('sky', 'Press on the ground, not the sky'); + onCancel?.('sky', msg('Press on the ground, not the sky')); return; } anchor = point; @@ -169,7 +170,8 @@ export function initImageryBoxTool({ corner = worldAt(event.position) || corner; const box = dragBox(); const result = validateBox(box); - if (result.reason === 'degenerate') result.message = 'Drag to size the box'; + if (result.reason === 'degenerate') + result.message = msg('Drag to size the box'); endDrag(); setActive(false); if (result.ok) onBox(result.box); diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 10ceb3c82..0641e22b2 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -130,9 +130,12 @@ export class LayerBindings { ]) { const layer = this._dataManager?.layers?.get(id)?.module; if (typeof layer?.attachShellServices !== 'function') continue; + // The Cockpit refusal names the destination: a cyclone layer flies to + // a storm, the others to their coverage area. + const noun = id === 'weather-cyclones' ? 'cyclone' : 'weather'; layer.attachShellServices({ runNavigation: (navigate) => - this.runImmediateNavigation('weather', navigate), + this.runImmediateNavigation(noun, navigate), imageryHost: this.services.imageryHost, }); this._weatherShellModules.push(layer); diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js new file mode 100644 index 000000000..3f3f953bf --- /dev/null +++ b/src/ui/layerNames.js @@ -0,0 +1,137 @@ +/** + * Layer and source names as the page shows them. + * + * Layer ids, English module names (`layer.name`) and declared source names + * stay English in layer state, share links and AI tool output; these helpers + * translate them only at a presentation boundary. The layer panel, the HUD + * and the voice card name a layer the same way through them. + * + * Pure: imports only i18n, no DOM. + * @module ui/layerNames + */ +import { msg, t, translateKnown } from '../i18n/index.js'; + +/** English module names (`layer.name`) by layer id. */ +export const LAYER_NAME_KEYS = Object.freeze({ + flights: msg('Live Flights'), + military: msg('Military Flights'), + earthquakes: msg('Earthquakes (24h)'), + satellites: msg('Satellites'), + 'rocket-launches': msg('Space Missions (30d)'), + traffic: msg('Street Traffic'), + cctv: msg('CCTV'), + radio: msg('Radio'), + bikeshare: msg('Bikeshare'), + 'ais-live-vessels': msg('Live AIS Vessels'), + 'military-installations': msg('Mapped Installations'), + 'military-awareness': msg('Global Context'), + 'local-datacenters': msg('Datacenters'), + 'local-dams': msg('Dams'), + 'telegeography-submarine-cables': msg('Submarine Cables'), + 'local-firms': msg('FIRMS Active Fires'), + 'local-adsb': msg('Local ADS-B'), + transit: msg('Transit'), + 'recent-imagery': msg('Recent Imagery'), + 'fire-perimeters': msg('Fire Perimeters'), + 'gnss-integrity': msg('GNSS Integrity'), + wind: msg('Wind'), + 'weather-radar': msg('Rain radar'), + 'weather-satellite': msg('Satellite clouds'), + 'weather-lightning': msg('Lightning density'), + 'weather-cyclones': msg('Cyclone advisories'), + directions: msg('Directions'), + 'alpr-cameras': msg('ALPR Cameras'), +}); + +/** The layer panel's own English names, where they differ from the module's. */ +export const PANEL_LABEL_KEYS = Object.freeze({ + 'ais-live-vessels': msg('Live Vessels'), + bikeshare: msg('Bike Share'), + cctv: msg('Cameras'), + 'street-level': msg('Street Level'), + 'alpr-cameras': msg('Mapped ALPR Cameras'), + 'local-datacenters': msg('Data Centers'), + 'local-firms': msg('Active Fires'), +}); + +// Looked up by the English text, so a surface that holds `layer.name` keeps +// its English data and only the shown text translates. +const MODULE_NAME_KEYS = new Map( + [...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map( + (key) => [key, key], + ), +); + +/** + * Translate a layer's English module name for display; any other text + * (a provider name, an unknown layer) is returned unchanged. + * @param {string} name English `layer.name`. + * @returns {string} + */ +export function localizeLayerName(name) { + const key = MODULE_NAME_KEYS.get(String(name ?? '').trim()); + return key ? t(key) : name; +} + +/** + * A layer id's module name in the active language, or null for an id this + * module does not know. + * @param {string} id Layer id, e.g. `earthquakes`. + * @returns {string|null} + */ +export function layerNameForId(id) { + const key = Object.hasOwn(LAYER_NAME_KEYS, id) + ? LAYER_NAME_KEYS[id] + : Object.hasOwn(PANEL_LABEL_KEYS, id) + ? PANEL_LABEL_KEYS[id] + : null; + return key ? t(key) : null; +} + +/** + * A layer as shown: its English module name or its id, whichever is known, + * in the active language; otherwise the text as given. + * @param {string} [name] English `layer.name`, or an id when the layer had none. + * @param {string} [id] Layer id. + * @returns {string} + */ +export function shownLayerName(name, id) { + const text = String(name ?? '').trim(); + if (text && MODULE_NAME_KEYS.has(text)) return t(MODULE_NAME_KEYS.get(text)); + return layerNameForId(id || text) || text || String(id ?? ''); +} + +// Layer-declared source names. Provider names (USGS, CelesTrak, GTFS-RT…) +// are not listed and pass through unchanged unless the catalog knows them. +const SOURCE_LABEL_KEYS = new Map( + [ + msg('Flights'), + msg('Military aircraft'), + msg('Vessels'), + msg('CCTV + Street View fallback'), + msg('OpenStreetMap + optional Google Maps Places'), + msg('GFS / ECMWF IFS · FORECAST'), + msg('NOAA nowCOAST · OBSERVED'), + msg('NASA FIRMS · LIVE'), + msg('OSM routing'), + msg('Mapped camera locations'), + msg('OpenStreetMap · community mapped'), + ].map((key) => [key, key]), +); + +/** + * A layer's declared source name as shown. + * @param {string} source English source name, e.g. `Flights` or `OpenSky`. + * @returns {string} + */ +export function sourceLabel(source) { + if (typeof source !== 'string') return source; + if (source === 'Local') return t('Local', { context: 'data source' }); + const key = SOURCE_LABEL_KEYS.get(source); + if (key) return t(key); + // A layer with several providers joins their names ("Mapillary · Google + // Street View"); a name with a translation shows it. + return source.includes(' · ') + ? source.split(' · ').map(translateKnown).join(' · ') + : translateKnown(source); +} diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ea716425b..ed76c8c44 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -4,8 +4,23 @@ import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { keyGuidanceText, keyRequirementText } from './keyGuidance.js'; +// Layer and source names at this presentation boundary: registry ids and +// English module names stay as they are; a layer without an entry renders its +// module name, and the panel's own English names win (PANEL_LABEL_KEYS). +import { + LAYER_NAME_KEYS, + PANEL_LABEL_KEYS as PANEL_LABELS, + sourceLabel, +} from './layerNames.js'; +export { localizeLayerName } from './layerNames.js'; import { createWeatherPanel } from './weatherPanel.js'; -import { formatNumber, msg, t, translateKnown } from '../i18n/index.js'; +import { + formatNumber, + getLocale, + msg, + t, + translateKnown, +} from '../i18n/index.js'; // Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA // hooks, CSS feed-* classes); only the final label value translates, at this @@ -20,39 +35,6 @@ const FEED_STATE_LABEL_KEYS = Object.freeze({ unavailable: msg('UNAVAILABLE'), }); -// Layer display names, localized at the same presentation boundary. Registry -// ids stay English; a layer without an entry here renders its module name, -// falling back to the English panel override when one exists. -const LAYER_NAME_KEYS = Object.freeze({ - flights: msg('Live Flights'), - military: msg('Military Flights'), - earthquakes: msg('Earthquakes (24h)'), - satellites: msg('Satellites'), - 'rocket-launches': msg('Space Missions (30d)'), - traffic: msg('Street Traffic'), - cctv: msg('CCTV'), - radio: msg('Radio'), - bikeshare: msg('Bikeshare'), - 'ais-live-vessels': msg('Live AIS Vessels'), - 'military-installations': msg('Mapped Installations'), - 'military-awareness': msg('Global Context'), - 'local-datacenters': msg('Datacenters'), - 'local-dams': msg('Dams'), - 'telegeography-submarine-cables': msg('Submarine Cables'), - 'local-firms': msg('FIRMS Active Fires'), - 'local-adsb': msg('Local ADS-B'), - transit: msg('Transit'), - 'recent-imagery': msg('Recent Imagery'), - 'fire-perimeters': msg('Fire Perimeters'), - 'gnss-integrity': msg('GNSS Integrity'), - wind: msg('Wind'), - 'weather-radar': msg('Rain radar'), - 'weather-satellite': msg('Satellite clouds'), - 'weather-lightning': msg('Lightning density'), - 'weather-cyclones': msg('Cyclone advisories'), - directions: msg('Directions'), -}); - function lifecycleStateLabel(lifecycleState) { return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING')); } @@ -126,15 +108,6 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) => const PANEL_POSITIONS = new Map( PANEL_ORDER.map(({ id }, index) => [id, index]), ); -const PANEL_LABELS = { - 'ais-live-vessels': msg('Live Vessels'), - bikeshare: msg('Bike Share'), - cctv: msg('Cameras'), - 'street-level': msg('Street Level'), - 'alpr-cameras': msg('Mapped ALPR Cameras'), - 'local-datacenters': msg('Data Centers'), - 'local-firms': msg('Active Fires'), -}; function englishPanelLabel(layer) { return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name; @@ -147,59 +120,6 @@ function panelLabel(layer) { return key ? t(key) : layer.name; } -// English module names (layer.name) that other surfaces print, such as the -// loading chip's layer roster. Looked up by the English text so that surface -// keeps its English output and only the presentation translates. -const MODULE_NAME_KEYS = new Map( - [ - ...Object.values(LAYER_NAME_KEYS), - ...Object.values(PANEL_LABELS), - msg('ALPR Cameras'), - ].map((key) => [key, key]), -); - -/** - * Translate a layer's English module name for display; any other text - * (a provider name, an unknown layer) is returned unchanged. - * @param {string} name English `layer.name`. - * @returns {string} - */ -export function localizeLayerName(name) { - const key = MODULE_NAME_KEYS.get(String(name ?? '').trim()); - return key ? t(key) : name; -} - -// Layer-declared source names stay English in layer state, share links and AI -// tool output; only the row's source line translates them. Provider names -// (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged. -const SOURCE_LABEL_KEYS = new Map( - [ - msg('Flights'), - msg('Military aircraft'), - msg('Vessels'), - msg('CCTV + Street View fallback'), - msg('OpenStreetMap + optional Google Maps Places'), - msg('GFS / ECMWF IFS · FORECAST'), - msg('NOAA nowCOAST · OBSERVED'), - msg('NASA FIRMS · LIVE'), - msg('OSM routing'), - msg('Mapped camera locations'), - msg('OpenStreetMap · community mapped'), - ].map((key) => [key, key]), -); - -function sourceLabel(source) { - if (typeof source !== 'string') return source; - if (source === 'Local') return t('Local', { context: 'data source' }); - const key = SOURCE_LABEL_KEYS.get(source); - if (key) return t(key); - // A layer with several providers joins their names ("Mapillary · Google - // Street View"); a name with a translation shows it. - return source.includes(' · ') - ? source.split(' · ').map(translateKnown).join(' · ') - : translateKnown(source); -} - /** * Guidance for a control a missing provider key is holding back. * @@ -224,6 +144,92 @@ export function layerKeyRequirementTooltip(layer = {}) { ); } +// Live-source failures arrive as English data (src/sources/live/contract.js, +// standalone.js, flights and military ingestion), some with a source name or a +// number in them; the row shows them in the UI language. Keep these in step +// with the producers. Anything else shows its catalog entry, or as it came. +const FEED_ERRORS = new Set([ + msg('OpenSky OAuth client missing/invalid'), + msg('OpenSky OAuth rejected credentials'), + msg('OpenSky username/password rejected'), + msg('OpenSky auth missing'), + msg('OpenSky auth required'), + msg('OpenSky auth invalid'), + msg('OpenSky auth failed'), +]); +const FEED_ERROR_PATTERNS = [ + // Street Level coverage errors (streetLevel/providers/kartaview/source.js), + // ahead of the generic " HTTP ". + [ + /^KartaView answered HTTP (\d+)$/, + ([, status]) => t('KartaView answered HTTP {status}', { status }), + ], + [ + /^KartaView refused the request \(code (-?\d+)\)$/, + ([, code]) => t('KartaView refused the request (code {code})', { code }), + ], + // Weather imagery (weather/source.js, infraredImage.js) and vector tile + // (sources/vectorTiles.js) failures carry their own wording; the generic + // pattern below would take "Weather image" for a source name. + [ + /^Weather (image )?HTTP (\d+)$/, + ([, image, status]) => + image + ? t('Weather image HTTP {status}', { status }) + : t('Weather HTTP {status}', { status }), + ], + [ + /^Vector tiles unavailable \(HTTP (\d+)\)$/, + ([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }), + ], + [ + /^Source snapshot (\d+) min old$/, + ([, minutes]) => t('Source snapshot {minutes} min old', { minutes }), + ], + [ + /^(.+) rate limited \(anonymous\)$/, + ([, source]) => + t('{source} rate limited (anonymous)', { source: sourceLabel(source) }), + ], + [ + /^(.+) rate limited$/, + ([, source]) => t('{source} rate limited', { source: sourceLabel(source) }), + ], + [ + /^(.+) HTTP (\d{3})$/, + ([, source, status]) => + t('{source} HTTP {status}', { source: sourceLabel(source), status }), + ], + [ + /^(.+) network error$/, + ([, source]) => + t('{source} network error', { source: sourceLabel(source) }), + ], + [ + /^Malformed (.+) aircraft rows$/, + ([, source]) => + t('Malformed {source} aircraft rows', { source: sourceLabel(source) }), + ], + [ + /^Malformed (.+) response$/, + ([, source]) => + t('Malformed {source} response', { source: sourceLabel(source) }), + ], +]; + +/** A layer's error text in the UI language (see FEED_ERRORS). */ +export function translateFeedError(text) { + if (typeof text !== 'string') return text; + if (FEED_ERRORS.has(text)) return t(text); + const known = translateKnown(text); + if (known !== text) return known; + for (const [pattern, format] of FEED_ERROR_PATTERNS) { + const match = pattern.exec(text); + if (match) return format(match); + } + return text; +} + const ROW_TEXT_SEPARATOR = ' · '; /** A loading label is English data, at times several joined by " · ". */ @@ -571,15 +577,19 @@ export class LayerPanel { /** * Write a row's count. A count that is a phrase carrying its own number - * ("24 nearby · 2 on screen") moves to its own line under the name: beside - * the toggle it squeezes the name to a word per line in any language. The - * "—" placeholder is marked so it can give way to a wide status chip. + * ("24 nearby · 2 on screen") moves to its own line under the name outside + * English: beside the toggle it squeezes a longer translated name to a word + * per line. English keeps the count beside the toggle. The "—" placeholder + * is marked so it can give way to a wide status chip (translations only, + * radio.css). */ _syncCount(row, count, stats) { const text = this._layerCountText(stats); setRowText(count, text); const phrase = - typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel); + getLocale() !== 'en' && + typeof stats.countLabel === 'string' && + /\d/.test(stats.countLabel); row.classList.toggle('data-toggle-row--count-line', phrase); count.classList.toggle('data-count-empty', text === '—'); } @@ -743,7 +753,7 @@ export class LayerPanel { { source }, ); } - const presentedError = translateKnown( + const presentedError = translateFeedError( stats.error || stats.lastError || stats.managerRefreshError, ); if (presentedError) { @@ -768,7 +778,7 @@ export class LayerPanel { typeof stats.statusMessage === 'string' && stats.statusMessage.trim() ) { - return `${source} · ${stats.statusMessage.trim()}`; + return `${source} · ${translateKnown(stats.statusMessage.trim())}`; } const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never'); if (stats.loading) { @@ -885,7 +895,7 @@ export class LayerPanel { : stateText; const keyGuidance = unavailable ? String( - translateKnown(layer.stats.error) || t('Data source unavailable'), + translateFeedError(layer.stats.error) || t('Data source unavailable'), ) : layerKeyRequirementTooltip(layer); // Name the missing key on the control itself: a row reading KEY REQUIRED diff --git a/src/ui/localAdsbGuidanceI18n.test.mjs b/src/ui/localAdsbGuidanceI18n.test.mjs new file mode 100644 index 000000000..c8c8c55c7 --- /dev/null +++ b/src/ui/localAdsbGuidanceI18n.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('Local ADS-B guidance hint is translated in the layer row (K-20)', async () => { + const { setLocale, t } = await import('../i18n/index.js'); + const { LayerPanel } = await import('./layerPanel.js'); + const { localAdsbStatus } = await import('../layers/localAdsb/status.js'); + const english = localAdsbStatus({ + receiver: { webUsbSupported: true, connected: false }, + feedState: null, + heard: 0, + }); + assert.equal( + LayerPanel.prototype._buildMetaText.call(LayerPanel.prototype, { + id: 'local-adsb', + enabled: true, + lifecycleState: 'enabled', + stats: { count: 0, lastUpdate: null, ...english }, + }), + 'RTL-SDR · WebUSB · connect a receiver in Radio', + ); + for (const locale of ['es', 'ru']) { + setLocale(locale); + for (const receiver of [ + { webUsbSupported: true, connected: false }, + { webUsbSupported: false, connected: false }, + { webUsbSupported: true, connected: true, mode: 'fm' }, + ]) { + const stats = localAdsbStatus({ receiver, feedState: null, heard: 0 }); + const text = LayerPanel.prototype._buildMetaText.call( + LayerPanel.prototype, + { + id: 'local-adsb', + enabled: true, + lifecycleState: 'enabled', + stats: { count: 0, lastUpdate: null, ...stats }, + }, + ); + assert.notEqual( + t(stats.statusMessage), + stats.statusMessage, + `${locale}: no catalog entry for "${stats.statusMessage}"`, + ); + assert.ok( + !text.includes(stats.statusMessage), + `${locale}: row shows English hint: ${text}`, + ); + } + } + setLocale('en'); +}); diff --git a/src/ui/localSdrAudioState.i18n.test.mjs b/src/ui/localSdrAudioState.i18n.test.mjs new file mode 100644 index 000000000..b9c0a6dec --- /dev/null +++ b/src/ui/localSdrAudioState.i18n.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { localSdrCardView } from './localSdrPresentation.js'; +import { setLocale } from '../i18n/index.js'; + +const fmStreaming = { + webUsbSupported: true, + connected: true, + status: 'streaming', + message: 'Streaming', + mode: 'fm', + frequencyHz: 98_500_000, + volume: 0.8, + gain: 'auto', + seeking: false, + seekMessage: '', + decodedMessages: 0, + messagesPerSecond: null, + aircraftHeard: 0, + aircraftPositioned: 0, + deviceLabel: 'RTL2838', + locationStatus: 'unknown', + audioState: 'running', + samplesPerSecond: 2_400_000, + workerBlocks: 3, + iqLevelDbfs: -20, + audioLevelDbfs: -12, +}; + +test('FM status line names the AudioContext state in the UI language', () => { + try { + for (const [locale, state] of [ + ['es', 'running'], + ['ru', 'running'], + ['es', 'suspended'], + ['ru', 'closed'], + ['es', 'idle'], + ['ru', 'idle'], + ]) { + setLocale(locale); + const text = localSdrCardView({ + ...fmStreaming, + audioState: state, + }).statusText; + assert.doesNotMatch( + text, + new RegExp(`audio[: ]+${state}\\b|аудио[: ]+${state}\\b`), + `${locale}: ${text}`, + ); + } + } finally { + setLocale('en'); + } +}); + +test('FM status line keeps the English AudioContext state words', () => { + setLocale('en'); + for (const state of ['running', 'suspended', 'closed', 'idle']) { + const text = localSdrCardView({ + ...fmStreaming, + audioState: state, + }).statusText; + assert.ok(text.endsWith(` · audio ${state}`), text); + } +}); diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js index 468360b64..51c0175dd 100644 --- a/src/ui/localSdrControls.js +++ b/src/ui/localSdrControls.js @@ -1,3 +1,4 @@ +import { t } from '../i18n/index.js'; import { internetRadioAudioActive, localSdrCardView, @@ -198,7 +199,7 @@ export class LocalSdrControls { // Internet radio just started: it takes over from local FM. if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) { void this.receiver.stop({ - message: 'Local FM stopped: internet radio started', + message: t('Local FM stopped: internet radio started'), }); } this._radioAudioActive = radioAudioActive; diff --git a/src/ui/localSdrFeedLine.i18n.test.mjs b/src/ui/localSdrFeedLine.i18n.test.mjs new file mode 100644 index 000000000..6fec081e9 --- /dev/null +++ b/src/ui/localSdrFeedLine.i18n.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { localSdrFeedLine } from './localSdrPresentation.js'; + +for (const locale of ['es', 'ru']) { + test(`decoder feed line is translated in ${locale}`, () => { + setLocale(locale); + try { + const feeds = [{ id: '1090', band: '1090', status: 'live' }]; + const polling = localSdrFeedLine({ + configured: true, + polling: true, + feeds: [{ ...feeds[0], status: 'unreachable' }], + }); + const idle = localSdrFeedLine({ + configured: true, + polling: false, + feeds, + }); + assert.doesNotMatch(polling, /Decoder feeds|unreachable/); + assert.match(polling, /1090/); + assert.doesNotMatch(idle, /Decoder feeds|read while Local ADS-B is on/); + assert.match(idle, /1090/); + } finally { + setLocale('en'); + } + }); +} + +test('an unknown feed status is shown as given', () => { + setLocale('en'); + const feeds = [{ band: '1090', status: 'odd' }]; + assert.equal( + localSdrFeedLine({ configured: true, polling: true, feeds }), + 'Decoder feeds: 1090 odd', + ); +}); + +// B6: a feed status agrees with "fuente" in Spanish; the shared "invalid" key +// (any other masculine noun) is left alone. +test('es: an invalid decoder feed reads "no válida"', () => { + setLocale('es'); + try { + const line = localSdrFeedLine({ + configured: true, + polling: true, + feeds: [ + { band: '1090', status: 'live' }, + { band: '1090', status: 'invalid' }, + ], + }); + assert.match(line, /no válida/); + assert.doesNotMatch(line, /no válido\b/); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/localSdrFmStopMessage.i18n.test.mjs b/src/ui/localSdrFmStopMessage.i18n.test.mjs new file mode 100644 index 000000000..762d9faff --- /dev/null +++ b/src/ui/localSdrFmStopMessage.i18n.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { setLocale } from '../i18n/index.js'; +import { SdrController } from '../sdr/controller.js'; +import { LocalSdrControls } from './localSdrControls.js'; +import { localSdrCardView } from './localSdrPresentation.js'; + +function stubDocument() { + const elements = new Map(); + const doc = { + activeElement: null, + getElementById(id) { + if (!elements.has(id)) { + const listeners = new Map(); + elements.set(id, { + id, + value: '', + hidden: false, + disabled: false, + textContent: '', + ownerDocument: doc, + classList: { toggle() {} }, + setAttribute() {}, + closest: () => null, + addEventListener(type, handler) { + listeners.set(type, handler); + }, + }); + } + return elements.get(id); + }, + }; + return doc; +} + +function stubRadio() { + const listeners = new Set(); + return { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + stopPlayback() {}, + emit(state) { + for (const fn of listeners) fn(state); + }, + }; +} + +async function stopLocalFmForInternetRadio(t, locale) { + setLocale(locale); + const receiver = new SdrController({ webUsb: {}, storage: null }); + // Pretend local FM is streaming (no real device: stop() has nothing to tear down). + receiver._setState({ connected: true, status: 'streaming', mode: 'fm' }); + const radio = stubRadio(); + const controls = new LocalSdrControls({ + document: stubDocument(), + receiver, + radio, + }); + t.after(() => { + controls.destroy(); + setLocale('en'); + }); + + radio.emit({ audioState: 'playing' }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(receiver.state.connected, false); + return localSdrCardView(receiver.state); +} + +for (const locale of ['es', 'ru']) { + test(`FM stop message after internet radio start is shown in ${locale}`, async (t) => { + const view = await stopLocalFmForInternetRadio(t, locale); + assert.notEqual( + view.connectLabel, + 'CONNECT', + 'surrounding UI is translated', + ); + assert.notEqual( + view.statusText, + 'Local FM stopped: internet radio started', + `status must not be the English literal under ${locale}`, + ); + }); +} + +test('FM stop message after internet radio start reads as before in English', async (t) => { + const view = await stopLocalFmForInternetRadio(t, 'en'); + assert.equal(view.statusText, 'Local FM stopped: internet radio started'); +}); diff --git a/src/ui/localSdrNumbers.i18n.test.mjs b/src/ui/localSdrNumbers.i18n.test.mjs new file mode 100644 index 000000000..5765f12ec --- /dev/null +++ b/src/ui/localSdrNumbers.i18n.test.mjs @@ -0,0 +1,50 @@ +// C14: the SDR status line and stats write numbers in the UI language, like +// the frequency next to them ("Сканирование 101,1 МГц…"). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { localSdrCardView } from './localSdrPresentation.js'; + +const fm = { + connected: true, + status: 'streaming', + mode: 'fm', + message: 'Tuned', + seekMessage: '', + samplesPerSecond: 2_400_000, + iqLevelDbfs: -21.44, + audioLevelDbfs: -18.26, + workerBlocks: 0, + audioState: 'running', + gain: 20.7, + frequencyHz: 101_100_000, + volume: 0.5, + messagesPerSecond: 13.8, + webUsbSupported: true, +}; + +test('en: SDR readings keep their English digits', () => { + setLocale('en'); + const view = localSdrCardView(fm); + assert.match(view.statusText, /· 2\.40 MS\/s IQ ·/); + assert.match(view.statusText, /· RF -21\.4 dBFS ·/); + assert.match(view.statusText, /-18\.3 dBFS audio/); +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: SDR readings use the locale's decimal comma`, () => { + setLocale(locale); + try { + const view = localSdrCardView(fm); + assert.match(view.statusText, /· 2,40 MS\/s IQ ·/); + assert.match(view.statusText, /· RF -21,4 dBFS ·/); + assert.match(view.statusText, /-18,3 dBFS/); + const adsb = localSdrCardView({ ...fm, mode: 'adsb' }); + assert.equal(adsb.stats.rate, '13,8'); + assert.equal(adsb.stats.iq, '-21,4 dBFS'); + assert.equal(adsb.gainValue, '20.7', 'input values stay machine'); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/localSdrPresentation.js b/src/ui/localSdrPresentation.js index 7b3aabd61..505c1bad0 100644 --- a/src/ui/localSdrPresentation.js +++ b/src/ui/localSdrPresentation.js @@ -1,5 +1,5 @@ import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js'; -import { msg, t, tn } from '../i18n/index.js'; +import { formatNumber, msg, t, tn } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; /** @@ -22,6 +22,39 @@ function statusLabel(status) { return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase(); } +// AudioContext.state values (plus the controller's "idle") as shown in the +// FM status line; the value itself stays machine. +const AUDIO_STATE_LABELS = Object.freeze({ + idle: msg('idle'), + running: msg('running'), + suspended: msg('suspended'), + closed: msg('closed'), +}); + +function audioStateLabel(audioState) { + const key = String(audioState || 'idle'); + return AUDIO_STATE_LABELS[key] ? t(AUDIO_STATE_LABELS[key]) : key; +} + +// Decoder feed statuses from the server route, as shown on the card. They +// carry their own context: the word agrees with "feed" (es "fuente no válida"), +// unlike the shared "invalid" / "stale" elsewhere. +function feedStatusLabel(status) { + const key = String(status || 'unreachable'); + switch (key) { + case 'live': + return t('live', { context: 'feed status' }); + case 'stale': + return t('stale', { context: 'feed status' }); + case 'unreachable': + return t('unreachable', { context: 'feed status' }); + case 'invalid': + return t('invalid', { context: 'feed status' }); + default: + return key; + } +} + /** * Text for a numeric gain option: the option value stays the machine value * ("28.0"); only the label follows the locale ("28,0 дБ"). @@ -39,9 +72,18 @@ function formatGain(gain) { return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1); } +/** A reading with a fixed number of decimals, in the UI language ("2,40"). */ +function fixed(value, digits) { + return formatNumber(value, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + useGrouping: false, + }); +} + function formatIq(state) { return Number.isFinite(state.iqLevelDbfs) - ? `${state.iqLevelDbfs.toFixed(1)} dBFS` + ? `${fixed(state.iqLevelDbfs, 1)} dBFS` : '--'; } @@ -49,7 +91,7 @@ function statusText(state) { const fmActive = state.mode === 'fm'; const iqStatus = state.samplesPerSecond > 0 - ? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ` + ? `${fixed(state.samplesPerSecond / 1_000_000, 2)} MS/s IQ` : t('waiting for IQ'); const device = state.deviceLabel ? ` · ${state.deviceLabel}` : ''; if (state.connected && !fmActive) { @@ -70,9 +112,11 @@ function statusText(state) { : t('DSP waiting'); const rfStatus = `RF ${formatIq(state)}`; const audioSignal = Number.isFinite(state.audioLevelDbfs) - ? t('{level} dBFS audio', { level: state.audioLevelDbfs.toFixed(1) }) + ? t('{level} dBFS audio', { level: fixed(state.audioLevelDbfs, 1) }) : t('audio signal --'); - const audio = t('audio {state}', { state: state.audioState }); + const audio = t('audio {state}', { + state: audioStateLabel(state.audioState), + }); return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`; } return detail; @@ -121,7 +165,7 @@ export function localSdrCardView(state) { statsHidden: !(adsbActive && state.connected), stats: { rate: Number.isFinite(state.messagesPerSecond) - ? state.messagesPerSecond.toFixed(1) + ? fixed(state.messagesPerSecond, 1) : '--', heard: String(state.aircraftHeard || 0), positioned: String(state.aircraftPositioned || 0), @@ -174,9 +218,9 @@ export function localSdrFeedLine(feedState) { const names = feeds.map((feed) => { const name = localReceiverFeedName(feed, feeds); return feedState.polling || feed?.status === 'invalid' - ? `${name} ${feed?.status || 'unreachable'}` + ? `${name} ${feedStatusLabel(feed?.status)}` : name; }); - const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on'; - return `Decoder feeds: ${names.join(' · ')}${suffix}`; + const suffix = feedState.polling ? '' : t(' · read while Local ADS-B is on'); + return `${t('Decoder feeds: {feeds}', { feeds: names.join(' · ') })}${suffix}`; } diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs new file mode 100644 index 000000000..04121dd11 --- /dev/null +++ b/src/ui/localeSwitchReload.test.mjs @@ -0,0 +1,142 @@ +// K-27: the dock language switch reloads the page, so it asks first while the +// reload would lose work (unsaved CCTV calibration, the open cockpit, anything +// a module registers). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { + describeUnsavedWork, + msg, + registerUnsavedWork, + setLocale, +} from '../i18n/index.js'; + +function fakeButton() { + const listeners = {}; + return { + dataset: {}, + classList: { toggle() {} }, + setAttribute() {}, + addEventListener: (type, listener) => (listeners[type] = listener), + click: () => listeners.click(), + }; +} + +/** Render the switch into a fake dock with the given shell state. */ +function renderSwitch({ calDirty = false, cockpit = false } = {}) { + const buttons = []; + const previous = { + document: globalThis.document, + location: globalThis.location, + history: globalThis.history, + confirm: globalThis.confirm, + }; + const reloads = []; + const prompts = []; + let answer = true; + globalThis.document = { + querySelector: () => ({ appendChild: (button) => buttons.push(button) }), + createElement: fakeButton, + }; + globalThis.location = { + href: 'https://gev.example/#v=2', + assign() {}, + reload: () => reloads.push(true), + }; + globalThis.history = { replaceState() {} }; + globalThis.confirm = (text) => { + prompts.push(text); + return answer; + }; + const shell = { + services: { + cctvLayer: { + getUIState: () => ({ cameras: [{ calDirty: false }, { calDirty }] }), + }, + }, + cockpitView: { active: cockpit }, + _confirmLocaleReload: StyleManager.prototype._confirmLocaleReload, + }; + StyleManager.prototype._initLocaleSelector.call(shell); + const button = (locale) => + buttons.find((candidate) => candidate.dataset.locale === locale); + return { + button, + reloads, + prompts, + answer: (value) => (answer = value), + restore() { + for (const unregister of shell._unsavedWorkChecks) unregister(); + Object.assign(globalThis, previous); + }, + }; +} + +const storage = globalThis.localStorage; +test.before(() => { + globalThis.localStorage = { setItem() {}, getItem: () => null }; +}); +test.after(() => { + globalThis.localStorage = storage; +}); + +test('nothing to lose: the switch reloads without asking', () => { + const view = renderSwitch(); + try { + view.button('es').click(); + assert.deepEqual(view.prompts, []); + assert.equal(view.reloads.length, 1); + } finally { + view.restore(); + } +}); + +test('unsaved calibration or the open cockpit: the switch asks first', () => { + const view = renderSwitch({ calDirty: true, cockpit: true }); + try { + view.answer(false); + view.button('ru').click(); + assert.equal(view.prompts.length, 1); + assert.match(view.prompts[0], /unsaved CCTV calibration/); + assert.match(view.prompts[0], /the open cockpit/); + assert.equal(view.reloads.length, 0, 'declined: no reload'); + view.answer(true); + view.button('ru').click(); + assert.equal(view.reloads.length, 1, 'confirmed: reload'); + } finally { + view.restore(); + } +}); + +test('modules register their own unsaved work', () => { + const unregister = registerUnsavedWork(() => msg('drawings on the map')); + const failing = registerUnsavedWork(() => { + throw new Error('broken check'); + }); + const view = renderSwitch(); + try { + view.answer(false); + view.button('es').click(); + assert.match(view.prompts[0], /drawings on the map/); + assert.equal(view.reloads.length, 0); + } finally { + view.restore(); + unregister(); + failing(); + } + assert.deepEqual(describeUnsavedWork(), []); +}); + +test('the prompt reads in the active language', () => { + setLocale('es'); + const view = renderSwitch({ calDirty: true }); + try { + view.answer(false); + view.button('en').click(); + assert.equal(view.prompts.length, 1); + assert.doesNotMatch(view.prompts[0], /Switching language/); + } finally { + view.restore(); + setLocale('en'); + } +}); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index f2dc0e424..791287cb6 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -1,5 +1,6 @@ import { locationMiniStatus } from '../locationStatus.js'; import { t } from '../i18n/index.js'; +import { localizePlaceName } from './placeNames.js'; const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; @@ -51,7 +52,7 @@ export class LocationControls { pill.type = 'button'; pill.className = 'location-pill'; pill.dataset.locationId = id; - pill.textContent = city.name; + pill.textContent = localizePlaceName(city.name); this.bind(pill, 'click', () => onCity(id)); elements.pills.appendChild(pill); } @@ -142,9 +143,16 @@ export class LocationControls { for (const pill of this.elements.pills.querySelectorAll('.location-pill')) pill.classList.toggle('active', pill.dataset.locationId === id); } - renderStatus(state) { + renderStatus(record) { if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi) return; + // A preset city shows its name in the UI language; the record stays English. + const state = record?.city?.name + ? { + ...record, + city: { ...record.city, name: localizePlaceName(record.city.name) }, + } + : record; const lines = locationMiniStatus(state); // The initial/placeholder states get catalog keys at the writer so a // non-English boot never leaks the English sentinels back over the diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 3b6703152..07ce43d8d 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -119,7 +119,7 @@ export class LocationNavigation { }), onResult: (destination) => { if (destination.outlineUnavailable) - this._showToast('Detailed outline unavailable'); + this._showToast(t('Detailed outline unavailable')); }, onError: (error) => console.error('[Search] Geocoding failed:', error), }); diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 70b9bab86..41bb67830 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,5 +1,5 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ -import { t } from '../i18n/index.js'; +import { t, msg } from '../i18n/index.js'; import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; @@ -32,7 +32,7 @@ const VIEWPORT_MARGIN_PX = 6; */ const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control']; const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; -const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; +const PANEL_FLOAT_HINT = msg('Double-click the header to snap the panel back'); /** Header children whose own interaction wins over a drag or a snap-back. */ const INTERACTIVE_SELECTOR = 'input, select, option, textarea, button, a, [role="button"]'; @@ -477,7 +477,7 @@ export class PanelPositionControls { if (this._floatHintShown) return; this._floatHintShown = true; } - this._showToast(PANEL_FLOAT_HINT); + this._showToast(t(PANEL_FLOAT_HINT)); } /** diff --git a/src/ui/placeNames.js b/src/ui/placeNames.js new file mode 100644 index 000000000..e57fb16b4 --- /dev/null +++ b/src/ui/placeNames.js @@ -0,0 +1,44 @@ +/** + * Preset place names as the page shows them. + * + * The preset cities in src/locations.js keep their English names in data: + * those are what voice tools, the AI model, share links and lookups match on. + * This helper translates a known English name at a presentation boundary + * only; any other place (a geocoded address, a name the model chose) is shown + * as given. + * + * Pure: imports only i18n, no DOM. + * @module ui/placeNames + */ +import { msg, t } from '../i18n/index.js'; + +// The preset cities (CITY_POIS names, kept in step by placeNames.test.mjs), +// and Los Angeles, which voice results name for the analyst's region scopes. +const PLACE_NAME_KEYS = new Map( + [ + msg('Austin'), + msg('San Francisco'), + msg('New York'), + msg('Tokyo'), + msg('London'), + msg('Paris'), + msg('Dubai'), + msg('Washington DC'), + msg('Tallinn'), + msg('Los Angeles'), + ].map((key) => [key, key]), +); + +/** + * A preset place name in the active language; any other text unchanged. + * @param {string} name English place name, e.g. `Tokyo`. + * @returns {string} + */ +export function localizePlaceName(name) { + if (typeof name !== 'string') return name; + const key = PLACE_NAME_KEYS.get(name.trim()); + return key ? t(key) : name; +} + +/** The English names this module translates (for tests). */ +export const KNOWN_PLACE_NAMES = Object.freeze([...PLACE_NAME_KEYS.keys()]); diff --git a/src/ui/placeNames.test.mjs b/src/ui/placeNames.test.mjs new file mode 100644 index 000000000..c84742321 --- /dev/null +++ b/src/ui/placeNames.test.mjs @@ -0,0 +1,82 @@ +// Preset place names show in the UI language at the presentation boundary +// (voice results, location pills, HUD); the preset data stays English. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { CITY_POIS } from '../locations.js'; +import { KNOWN_PLACE_NAMES, localizePlaceName } from './placeNames.js'; +import { LocationControls } from './locationControls.js'; + +test.afterEach(() => setLocale('en')); + +test('every preset city has a display name entry', () => { + for (const city of Object.values(CITY_POIS)) + assert.ok(KNOWN_PLACE_NAMES.includes(city.name), city.name); +}); + +test('en: place names pass through unchanged', () => { + setLocale('en'); + for (const name of KNOWN_PLACE_NAMES) + assert.equal(localizePlaceName(name), name); +}); + +for (const [locale, tokyo, losAngeles] of [ + ['ru', 'Токио', 'Лос-Анджелес'], + ['es', 'Tokio', 'Los Ángeles'], +]) { + test(`${locale}: preset names are translated, other places shown as given`, () => { + setLocale(locale); + assert.equal(localizePlaceName('Tokyo'), tokyo); + assert.equal(localizePlaceName('Los Angeles'), losAngeles); + assert.equal(localizePlaceName('Ulaanbaatar'), 'Ulaanbaatar'); + assert.equal(localizePlaceName(undefined), undefined); + }); +} + +function element() { + return { + children: [], + dataset: {}, + textContent: '', + addEventListener() {}, + removeEventListener() {}, + appendChild(child) { + this.children.push(child); + }, + replaceChildren() { + this.children = []; + }, + }; +} + +test('ru: location pills and the mini-status show the translated city', () => { + setLocale('ru'); + const elements = { + pills: element(), + poiRow: element(), + divider: element(), + search: element(), + searchToggle: element(), + resetButtons: [], + statusCity: element(), + statusPoi: element(), + }; + const doc = { ...element(), createElement: element }; + const cities = { tokyo: CITY_POIS.tokyo }; + const controls = new LocationControls({ + elements, + cities, + getExpandedCity: () => null, + onCity() {}, + onPoi() {}, + onSearch() {}, + onReset() {}, + doc, + requestFrame: () => 0, + cancelFrame() {}, + }); + assert.equal(elements.pills.children[0].textContent, 'Токио'); + controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null }); + assert.equal(elements.statusCity.textContent, '📍 Токио'); + assert.equal(CITY_POIS.tokyo.name, 'Tokyo'); +}); diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index 36b0e4dce..85bf08bcb 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -179,9 +179,11 @@ export class RadioControls { 'aria-expanded', String(radioExpanded), ); - const label = radioExpanded - ? 'Go to expanded Radio section' - : 'Expand Radio section in Context'; + const label = t( + radioExpanded + ? msg('Go to expanded Radio section') + : msg('Expand Radio section in Context'), + ); this._contextRadioToggleBtn.setAttribute('aria-label', label); this._contextRadioToggleBtn.title = label; return; diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 889c29bbf..fbd80c23a 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -32,10 +32,10 @@ export function renderRadioState(state) { } this._syncContextRadioLauncherState(); this._radioLayerState?.classList.toggle('active', enabled); + // Lifecycle enum values map to status-chip keys; counts stay machine. + const lifecycleLabel = + lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING'); if (this._radioLayerState) { - // Lifecycle enum values map to status-chip keys; counts stay machine. - const lifecycleLabel = - lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING'); this._radioLayerState.textContent = transitioning ? lifecycleLabel : uncertain @@ -307,7 +307,10 @@ export function renderRadioState(state) { buffering: t('Buffering broadcaster stream…'), playing: t('Playing {station}', { station: stationName }), paused: t('Paused {station}', { station: stationName }), - error: state.audioError || t('Broadcaster stream unavailable'), + // Playback keeps its messages in English (msg()); shown in the UI language. + error: state.audioError + ? t(state.audioError) + : t('Broadcaster stream unavailable'), }; const voiceSuffix = state.voiceDucked ? t(' · muted during voice interaction') @@ -332,7 +335,9 @@ export function renderRadioState(state) { const uncertainMessage = uncertain ? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile') : null; - this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; + // Directory errors are msg() literals from the radio layer's ingestion. + const directoryError = state.error ? t(state.error) : null; + this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`; this._radioPlaybackState.classList.toggle( 'error', Boolean( diff --git a/src/ui/radioPresentation.playbackError.test.mjs b/src/ui/radioPresentation.playbackError.test.mjs new file mode 100644 index 000000000..f5c59dd23 --- /dev/null +++ b/src/ui/radioPresentation.playbackError.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { renderRadioState } from './radioPresentation.js'; + +function el() { + const classes = new Set(); + return { + textContent: '', + classList: { + toggle(name, on) { + on ? classes.add(name) : classes.delete(name); + }, + contains: (name) => classes.has(name), + }, + }; +} + +function render(audioError) { + globalThis.document = { getElementById: () => null, activeElement: null }; + const host = { + destroyed: false, + _radioPanel: el(), + _radioPlaybackState: el(), + _syncContextRadioLauncherState() {}, + actions: { + getLifecycle: () => null, + preservePanelStateDuringClear: () => false, + setPanelCollapsed() {}, + scheduleLayout() {}, + }, + }; + renderRadioState.call(host, { + enabled: true, + audioState: 'error', + audioError, + filteredCount: 1, + stationCount: 1, + volume: 1, + }); + return host._radioPlaybackState.textContent; +} + +const PLAYBACK_ERRORS = [ + 'Playback requires a direct click or tap.', + 'Broadcaster stream could not be started.', + 'Broadcaster stream is unavailable or blocked by the browser.', +]; + +for (const locale of ['es', 'ru']) { + test(`radio playback errors are localized in ${locale}`, () => { + setLocale(locale); + try { + for (const message of PLAYBACK_ERRORS) { + assert.notEqual(render(message), message, `${locale}: ${message}`); + } + // Control: the fallback (no audioError) is translated. + assert.notEqual(render(null), 'Broadcaster stream unavailable'); + } finally { + setLocale('en'); + } + }); +} + +test('radio playback errors read as before in English', () => { + setLocale('en'); + for (const message of PLAYBACK_ERRORS) { + assert.equal(render(message), message); + } + assert.equal(render(null), 'Broadcaster stream unavailable'); +}); diff --git a/src/ui/radioPresentationTransition.test.mjs b/src/ui/radioPresentationTransition.test.mjs new file mode 100644 index 000000000..ca02adeeb --- /dev/null +++ b/src/ui/radioPresentationTransition.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { renderRadioState } from './radioPresentation.js'; + +function el() { + const attrs = new Map(); + return { + textContent: '', + hidden: false, + disabled: false, + style: {}, + dataset: {}, + classList: { toggle() {}, add() {}, remove() {}, contains: () => false }, + setAttribute: (k, v) => attrs.set(k, String(v)), + getAttribute: (k) => attrs.get(k), + attrs, + }; +} + +for (const lifecycleState of ['enabling', 'disabling']) { + test(`Radio render survives the ${lifecycleState} transition`, () => { + const prior = globalThis.document; + globalThis.document = { getElementById: () => null }; + try { + const enable = el(); + const ctx = { + destroyed: false, + _radioPanel: el(), + _radioEnableBtn: enable, + _syncContextRadioLauncherState() {}, + actions: { + getLifecycle: () => ({ + lifecycleState, + enabled: lifecycleState === 'disabling', + uncertain: false, + }), + }, + }; + const state = { + enabled: false, + categories: [], + filteredCount: 0, + stationCount: 0, + audioState: 'idle', + }; + try { + renderRadioState.call(ctx, state); + } catch (error) { + // Anything thrown after the transitional label is a different + // missing fixture field, not the bug under test. + assert.ok( + !( + error instanceof ReferenceError && + /lifecycleLabel/.test(error.message) + ), + `render threw: ${error.message}`, + ); + } + assert.equal(enable.attrs.get('aria-busy'), 'true'); + assert.equal(enable.textContent, lifecycleState.toUpperCase()); + } finally { + globalThis.document = prior; + } + }); +} diff --git a/src/ui/railTimeline.js b/src/ui/railTimeline.js index f3e784d6f..b96ded34e 100644 --- a/src/ui/railTimeline.js +++ b/src/ui/railTimeline.js @@ -2,9 +2,18 @@ import { formatUtcDayMonth, t } from '../i18n/index.js'; const set = (node, key, value) => { if (node[key] !== value) node[key] = value; }; +/** + * "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather + * than cut from the dated readout: the localized day and month vary in length + * ("3/9" in Spanish, "09-03" in English). + */ +export const utcClock = (tick) => + Number.isFinite(Date.parse(tick)) + ? `${new Date(tick).toISOString().slice(11, 16)} UTC` + : ''; const utc = (tick) => Number.isFinite(Date.parse(tick)) - ? `${formatUtcDayMonth(tick)} ${new Date(tick).toISOString().slice(11, 16)} UTC` + ? `${formatUtcDayMonth(tick)} ${utcClock(tick)}` : ''; /** Stable native transport with local preview and a coalesced 150 ms drag commit. */ @@ -115,8 +124,8 @@ export function createRailTimeline({ String(Math.max(0, Math.min(props.index, ticks.length - 1))), ); valueText(ticks[Number(slider.value)]); - set(oldest, 'textContent', utc(ticks[0]).slice(6)); - set(newest, 'textContent', utc(ticks.at(-1)).slice(6)); + set(oldest, 'textContent', utcClock(ticks[0])); + set(newest, 'textContent', utcClock(ticks.at(-1))); set(readout, 'textContent', props.readout || ''); } for (const node of [slider, latest, play]) diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index c7828e174..21aad25a6 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -19,6 +19,7 @@ import { } from '../layers/recentImagery/model.js'; import { displayDay, displayMetres } from '../layers/recentImagery/display.js'; import { formatQuantity } from './unitLabels.js'; +import { pointerOwnerName } from '../data/pointerOwnerNames.js'; const PANEL_ID = 'recent-imagery-panel'; const OVERVIEW_MIN_BOX_KM = 25; @@ -32,12 +33,38 @@ const LAYER_NOTICES = new Set([ msg('Esri map unavailable · no swipe'), msg('Swipe needs a globe map'), msg('Imagery stays on Esri · CLEAR to use Google 3D'), + // Box refusals the layer writes itself (layers/recentImagery/index.js). + msg('Point the camera at the ground to use the view'), + msg('That point cannot anchor a box'), ]); const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/; +// Refusals with a value in them (layers/recentImagery/model.js and +// imageryBoxTool.js); fixed refusals there are msg() text for translateKnown. +const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/; +const POINTER_BUSY = /^(.+) is using the pointer — close it first$/; -function localizeNotice(text) { +/** + * The notice line in the UI language: notices and box refusals arrive in + * English. Exported for tests. + * @param {string} text + * @returns {string} + */ +export function localizeNotice(text) { if (!text) return ''; if (LAYER_NOTICES.has(text)) return t(text); + const polar = POLAR.exec(text); + if (polar) + return t('Imagery stops at ±{limit}° latitude', { + limit: formatNumber(Number(polar[1]), { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + }); + const busy = POINTER_BUSY.exec(text); + if (busy) + return t('{owner} is using the pointer — close it first', { + owner: translateKnown(pointerOwnerName(busy[1])), + }); const tooLarge = TOO_LARGE.exec(text); if (!tooLarge) return translateKnown(text); const values = { diff --git a/src/ui/recentImageryNotice.test.mjs b/src/ui/recentImageryNotice.test.mjs new file mode 100644 index 000000000..6428e0394 --- /dev/null +++ b/src/ui/recentImageryNotice.test.mjs @@ -0,0 +1,103 @@ +// K-43: box-tool and model refusals on the Recent Imagery notice line are +// shown in the UI language; the layer keeps them in English. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { setLocale } from '../i18n/index.js'; +import { validateBox } from '../layers/recentImagery/model.js'; +import { localizeNotice } from './recentImagery.js'; + +const REFUSALS = [ + 'Drag to size the box', // imageryBoxTool.js, click without drag + 'Press on the ground, not the sky', // imageryBoxTool.js + 'draw is using the pointer — close it first', // imageryBoxTool.js, pointer busy + 'Point the camera at the ground to use the view', // layers/recentImagery/index.js + 'That point cannot anchor a box', // layers/recentImagery/index.js + ...[ + { west: 170, south: 10, east: -170, north: 20 }, // dateline + { west: 10, south: 80, east: 20, north: 89 }, // polar + { west: 10, south: 10, east: 10, north: 20 }, // degenerate + { west: 10, south: 10, east: 200, north: 20 }, // longitude range + { west: Number.NaN, south: 10, east: 20, north: 20 }, // not finite + ].map((box) => { + const result = validateBox(box); + assert.equal(result.ok, false); + return result.message; + }), +]; + +// The pointer-busy refusal names the owner by its internal id; the notice +// shows the tool's name instead, in English too. +const SHOWN_EN = new Map([ + [ + 'draw is using the pointer — close it first', + 'Draw is using the pointer — close it first', + ], +]); + +test('K-43 en: refusals read as before', () => { + setLocale('en'); + for (const text of REFUSALS) + assert.equal(localizeNotice(text), SHOWN_EN.get(text) ?? text); + assert.ok(REFUSALS.includes('Imagery stops at ±85.05° latitude')); +}); + +test('E3/E12: the pointer owner is named by its tool, never by its id', () => { + const owners = { + en: { + draw: 'Draw', + directions: 'Directions', + 'recent-imagery-box': 'Recent imagery', + }, + ru: { + draw: 'Рисование', + directions: 'Маршруты', + 'recent-imagery-box': 'Свежие снимки', + }, + es: { + draw: 'Dibujo', + directions: 'Indicaciones', + 'recent-imagery-box': 'Imágenes recientes', + }, + }; + try { + for (const [locale, names] of Object.entries(owners)) { + setLocale(locale); + for (const [id, name] of Object.entries(names)) { + const shown = localizeNotice( + `${id} is using the pointer — close it first`, + ); + assert.ok(shown.includes(name), `${locale} ${id}: ${shown}`); + assert.ok(!shown.includes(id), `${locale} ${id} leaked: ${shown}`); + } + } + setLocale('en'); + assert.equal( + localizeNotice('measure is using the pointer — close it first'), + 'measure is using the pointer — close it first', + 'an unregistered owner falls back to its id', + ); + } finally { + setLocale('en'); + } +}); + +for (const locale of ['es', 'ru']) { + test(`K-43 ${locale}: box-tool and model refusals are translated`, () => { + setLocale(locale); + try { + for (const text of REFUSALS) + assert.notEqual(localizeNotice(text), text, `untranslated: ${text}`); + assert.match( + localizeNotice('Imagery stops at ±85.05° latitude'), + /85,05/, + ); + assert.doesNotMatch( + localizeNotice('draw is using the pointer — close it first'), + /\bdraw\b/, + ); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 69777708d..ce0fc4027 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -1,5 +1,5 @@ /** Own Scene panel listeners and presentation through project reads and actions. */ -import { t } from '../i18n/index.js'; +import { t, msg } from '../i18n/index.js'; import { sceneElements, renderSceneOptions, @@ -46,7 +46,7 @@ export class SceneControls { this.listen(elements.start, 'click', () => this.run('start', this.read().selectedSceneId), ); - this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped')); + this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped'))); this.listen(elements.import, 'click', () => elements.file?.click()); this.listen(elements.file, 'change', async () => { const file = elements.file?.files?.[0]; @@ -141,8 +141,8 @@ export class SceneControls { createScene() { if (this.destroyed) return; const name = window.prompt( - 'New scene name', - `Scene ${this.read().scenes.length + 1}`, + t('New scene name'), + t('Scene {n}', { n: this.read().scenes.length + 1 }), ); if (name) this.run('create', name); } @@ -153,7 +153,12 @@ export class SceneControls { const scene = state.scenes.find( (item) => item.id === state.selectedSceneId, ); - if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`)) + if ( + scene && + window.confirm( + t('Delete scene "{scene}" and all shots?', { scene: scene.title }), + ) + ) this.run('deleteScene'); } @@ -221,7 +226,7 @@ export class SceneControls { } else if (!this.destroyed && !this.playbackKeyRemover) { const onKeyDown = (event) => { if (!this.destroyed && event.key === 'Escape' && this.read().running) - this.run('stop', 'Stopped (Esc)'); + this.run('stop', msg('Stopped (Esc)')); }; document.addEventListener('keydown', onKeyDown); this.playbackKeyRemover = () => diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index b234eebf1..f37f33d1b 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -87,21 +87,25 @@ export function createSceneDialog(title, onClose) { } /** * The browser draws a file input's button and "no file" text in its own - * language. Hide it visually (it stays focusable and is still opened by its - * label) and show a translated button and selection line instead. + * language. Keep the native input itself as the whole control — stretched + * over the row below, fully transparent — so clicking, keyboard focus and + * dropping files or folders from the desktop all stay native (a shrunken + * input would only accept drops on its own 1 px), and draw a translated + * button and selection line underneath it. */ function fileChooser(wrapper, node) { wrapper.style.position = 'relative'; Object.assign(node.style, { position: 'absolute', - width: '1px', - height: '1px', + inset: '0', + width: '100%', + height: '100%', margin: '0', padding: '0', - overflow: 'hidden', - clip: 'rect(0 0 0 0)', - whiteSpace: 'nowrap', border: '0', + opacity: '0', + cursor: 'pointer', + zIndex: '1', }); const row = document.createElement('span'); row.setAttribute('aria-hidden', 'true'); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index c0b98ec56..52c48c95b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,56 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t } from '../i18n/index.js'; +import { t, msg } from '../i18n/index.js'; import { UiLifetime } from './uiLifetime.js'; +// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES +// label) and outcome: a label dropped into a template cannot take the case or +// article the sentence needs in every language. The generic template stays +// for a label this table does not know. +const SHARE_RESTORE_NOTICES = Object.freeze({ + expired: Object.freeze({ + flight: msg('Shared flight follow expired'), + 'military flight': msg('Shared military flight follow expired'), + satellite: msg('Shared satellite follow expired'), + }), + 'source-unavailable': Object.freeze({ + flight: msg('Shared flight could not be restored — feed unavailable'), + 'military flight': msg( + 'Shared military flight could not be restored — feed unavailable', + ), + satellite: msg('Shared satellite could not be restored — feed unavailable'), + }), + unavailable: Object.freeze({ + flight: msg('Shared flight is unavailable'), + 'military flight': msg('Shared military flight is unavailable'), + satellite: msg('Shared satellite is unavailable'), + }), +}); + +/** + * The status notice for a terminal share-tracking restore result. + * @param {{classification?: string, label?: string}} result + * @returns {string} + */ +export function shareTrackingRestoreNotice(result) { + const outcome = + result?.classification === 'expired' || + result?.classification === 'source-unavailable' + ? result.classification + : 'unavailable'; + const ready = SHARE_RESTORE_NOTICES[outcome][result?.label]; + if (ready) return t(ready); + const subject = result?.label ? t(result.label) : t('entity'); + return outcome === 'expired' + ? t('Shared {subject} follow expired', { subject }) + : outcome === 'source-unavailable' + ? t('Shared {subject} could not be restored — feed unavailable', { + subject, + }) + : t('Shared {subject} is unavailable', { subject }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -219,15 +266,7 @@ export class ShareRestoration { const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const subject = result.label ? t(result.label) : t('entity'); - const message = - result.classification === 'expired' - ? t('Shared {subject} follow expired', { subject }) - : result.classification === 'source-unavailable' - ? t('Shared {subject} could not be restored — feed unavailable', { - subject, - }) - : t('Shared {subject} is unavailable', { subject }); + const message = shareTrackingRestoreNotice(result); const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( diff --git a/src/ui/shareRestorationNotice.test.mjs b/src/ui/shareRestorationNotice.test.mjs new file mode 100644 index 000000000..aed860192 --- /dev/null +++ b/src/ui/shareRestorationNotice.test.mjs @@ -0,0 +1,79 @@ +// Share-tracking restore notices are ready-made sentences per policy: a label +// dropped into a template cannot take the case (ru) or article (es) the +// sentence needs. English stays exactly as it was composed before. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { SHARE_TRACKING_RESTORE_POLICIES } from '../data/layerState.js'; +import { shareTrackingRestoreNotice } from './shareRestoration.js'; + +const { flights, military, satellites } = SHARE_TRACKING_RESTORE_POLICIES; + +test.afterEach(() => setLocale('en')); + +test('en: the notices read as before for every policy and outcome', () => { + setLocale('en'); + const notice = (policy, classification) => + shareTrackingRestoreNotice({ ...policy, classification }); + assert.equal(notice(flights, 'expired'), 'Shared flight follow expired'); + assert.equal( + notice(military, 'expired'), + 'Shared military flight follow expired', + ); + assert.equal( + notice(satellites, 'source-unavailable'), + 'Shared satellite could not be restored — feed unavailable', + ); + assert.equal( + notice(flights, 'source-unavailable'), + 'Shared flight could not be restored — feed unavailable', + ); + assert.equal( + notice(military, 'unavailable'), + 'Shared military flight is unavailable', + ); + assert.equal( + notice(satellites, 'unavailable'), + 'Shared satellite is unavailable', + ); + // Unknown label and classification: the generic template still applies. + assert.equal( + shareTrackingRestoreNotice({ label: 'contact', classification: 'odd' }), + 'Shared contact is unavailable', + ); + assert.equal( + shareTrackingRestoreNotice({ classification: 'expired' }), + 'Shared entity follow expired', + ); +}); + +test('ru: the restore notices do not glue the nominative label into a genitive slot', () => { + setLocale('ru'); + for (const policy of [flights, military, satellites]) { + for (const classification of [ + 'expired', + 'source-unavailable', + 'unavailable', + ]) { + const message = shareTrackingRestoreNotice({ ...policy, classification }); + // "Срок отслеживания" takes the genitive: рейса / спутника, never the + // nominative label as stored in the policy. + assert.doesNotMatch( + message, + /отслеживания (военный )?(рейс|спутник) /, + message, + ); + assert.doesNotMatch(message, /Shared|\{subject\}/, message); + } + } +}); + +test('es: the restore notices read with an article before the subject', () => { + setLocale('es'); + const message = shareTrackingRestoreNotice({ + ...flights, + classification: 'expired', + }); + assert.doesNotMatch(message, /seguimiento de vuelo/, message); + assert.doesNotMatch(message, /Shared/, message); +}); diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index d8a77dbc3..bf0aa0744 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -9,8 +9,8 @@ import { reduceTrafficSyncFeedback, } from '../loadingFeedback.js'; import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js'; -import { t, msg } from '../i18n/index.js'; -import { localizeLayerName } from './layerPanel.js'; +import { getLocale, t, msg } from '../i18n/index.js'; +import { localizeLayerName, translateFeedError } from './layerPanel.js'; // loadingFeedback.js composes the shared status copy inside the portable // node/browser graph (the server-side HUD summary reads the same modules), so @@ -29,24 +29,66 @@ const STATUS_LABEL_KEYS = Object.freeze({ 'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'), 'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'), 'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'), + // The other mapped-site failure reasons (src/data/installationFeedback.js), + // upper-cased by presentGlobalLoadingStatus. + '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', + ), + 'MAP TILES TEMPORARILY UNAVAILABLE': msg('MAP TILES TEMPORARILY UNAVAILABLE'), + 'MAPPED NAMES TEMPORARILY UNAVAILABLE': msg( + 'MAPPED NAMES TEMPORARILY UNAVAILABLE', + ), 'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'), 'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'), 'syncing road network': msg('syncing road network'), }); +// The ALPR retry chip shows the layer's English error upper-cased +// (loadingFeedback.js). These are the camera and vector tile failures it can +// carry, by their upper-cased text; each is translated through its own +// sentence-case key, the one the layer row shows, and upper-cased again. +const CHIP_ERROR_KEYS = new Map( + [ + msg('Vector tile metadata unavailable'), + msg('Vector tiles unavailable'), + msg('Vector tile request timed out'), + msg('Camera coverage unavailable'), + msg('Camera source temporarily unavailable'), + ].map((key) => [key.toUpperCase(), key]), +); +const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/; + function localizeStatusLabel(label) { if (typeof label !== 'string' || !label) return label; - const key = STATUS_LABEL_KEYS[label.trim()]; - return key ? t(key) : label; + const text = label.trim(); + const key = STATUS_LABEL_KEYS[text]; + if (key) return t(key); + const errorKey = CHIP_ERROR_KEYS.get(text); + const http = errorKey ? null : CHIP_TILE_HTTP_RE.exec(text); + if (!errorKey && !http) return label; + return translateFeedError( + errorKey || `Vector tiles unavailable (HTTP ${http[1]})`, + ).toLocaleUpperCase(getLocale()); } -const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/; +// A retry detail is "retrying in Ns" or "retry pending", optionally prefixed +// by the ALPR layer name (loadingFeedback.js). The prefix is kept in every +// language: it is what tells the chip apart from a mapped-sites retry. +const RETRY_DETAIL_RE = + /^(?:(ALPR cameras) · )?(?:retrying in (\d+)s|(retry pending))$/; function localizeStatusDetail(detail) { if (typeof detail !== 'string' || !detail) return detail; - const match = detail.match(RETRY_DETAIL_RE); - if (match) return t('retrying in {seconds}s', { seconds: match[1] }); - if (detail.trim() === 'retry pending') return t('retry pending'); + const match = detail.trim().match(RETRY_DETAIL_RE); + if (match) { + const [, prefix, seconds, pending] = match; + const retry = pending + ? t('retry pending') + : t('retrying in {seconds}s', { seconds }); + return prefix ? `${t('ALPR cameras')} · ${retry}` : retry; + } return localizeLayerRoster(detail); } @@ -64,7 +106,8 @@ function localizeLayerRoster(detail) { ); } -function localizeLoadingPresentation(presentation) { +/** The chip's label and detail in the UI language (exported for tests). */ +export function localizeLoadingPresentation(presentation) { if (!presentation) return presentation; return { ...presentation, diff --git a/src/ui/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs new file mode 100644 index 000000000..bcfba7a65 --- /dev/null +++ b/src/ui/shellFeedbackStatusLocalization.test.mjs @@ -0,0 +1,110 @@ +// The global loading chip translates the closed status vocabulary of +// loadingFeedback.js at the presentation boundary. The ALPR retry detail keeps +// its layer-name prefix in every language (it is what tells the chip apart +// from a mapped-sites retry), and every mapped-site failure reason label has a +// catalog entry, not only the default one. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; + +const elements = { + 'global-loading-status': { hidden: true, dataset: {} }, + 'global-loading-detail': { textContent: '' }, +}; +globalThis.document ??= { + getElementById: (id) => elements[id] ?? null, + addEventListener() {}, + removeEventListener() {}, + hidden: false, +}; +const { ShellFeedback, localizeLoadingPresentation } = + await import('./shellFeedback.js'); + +function alprChipDetail(locale, retryInMs) { + setLocale(locale); + const layer = { + id: 'alpr-cameras', + name: 'ALPR cameras', + enabled: true, + stats: { + status: 'unavailable', + error: 'Unavailable', + retryAt: Date.now() + retryInMs, + }, + }; + const feedback = new ShellFeedback({ readLayers: () => [layer] }); + feedback._armLoadingFeedbackTicker = () => {}; + feedback._updateGlobalLoadingFeedback(0); + return elements['global-loading-detail'].textContent; +} + +test.afterEach(() => setLocale('en')); + +test('English ALPR retry chip keeps the layer name (as on main)', () => { + assert.equal(alprChipDetail('en', 12000), 'ALPR cameras · retrying in 12s'); + assert.equal(alprChipDetail('en', -1000), 'ALPR cameras · retry pending'); +}); + +test('Spanish ALPR retry chip keeps the layer name', () => { + assert.match(alprChipDetail('es', 12000), /ALPR.* · reintento en 12 s/); +}); + +test('Spanish ALPR retry-pending chip is translated', () => { + const detail = alprChipDetail('es', -1000); + assert.match(detail, /ALPR/); + assert.doesNotMatch(detail, /retry pending/); +}); + +test('English presentation passes through unchanged', () => { + setLocale('en'); + for (const label of [ + 'OVERPASS TEMPORARILY UNAVAILABLE', + 'OVERPASS RATE-LIMITED', + 'OVERPASS TIMED OUT', + 'OVERPASS COULD NOT COMPLETE THE QUERY', + 'MAP TILES TEMPORARILY UNAVAILABLE', + 'MAPPED NAMES TEMPORARILY UNAVAILABLE', + ]) { + assert.deepEqual( + localizeLoadingPresentation({ + state: 'retry', + label, + detail: 'retrying in 12s', + }), + { state: 'retry', label, detail: 'retrying in 12s' }, + ); + } +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: every mapped-site retry label is translated with its detail`, () => { + setLocale(locale); + for (const label of [ + 'OVERPASS TEMPORARILY UNAVAILABLE', + 'OVERPASS RATE-LIMITED', + 'OVERPASS TIMED OUT', + 'OVERPASS COULD NOT COMPLETE THE QUERY', + 'MAP TILES TEMPORARILY UNAVAILABLE', + 'MAPPED NAMES TEMPORARILY UNAVAILABLE', + ]) { + const localized = localizeLoadingPresentation({ + state: 'retry', + label, + detail: 'retrying in 12s', + }); + assert.notEqual(localized.label, label, `label stays English: ${label}`); + assert.notEqual(localized.detail, 'retrying in 12s'); + } + }); + + test(`${locale}: a live provider error label passes through verbatim`, () => { + setLocale(locale); + const localized = localizeLoadingPresentation({ + state: 'retry', + label: 'HTTP 503 FROM OVERPASS-API.DE', + detail: 'retry pending', + }); + assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE'); + assert.notEqual(localized.detail, 'retry pending'); + }); +} diff --git a/src/ui/shellToasts.i18n.test.mjs b/src/ui/shellToasts.i18n.test.mjs new file mode 100644 index 000000000..0d83640d9 --- /dev/null +++ b/src/ui/shellToasts.i18n.test.mjs @@ -0,0 +1,108 @@ +// Shell toasts, prompts and launcher labels that reach the page through +// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant +// are invisible to the unmarked-text checker, so pin them here: marked in the +// code, present in every shipped catalog, and no bare literal left at the +// call sites. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + extractMessages, + readCatalog, + shippedLocales, +} from '../../scripts/i18n.mjs'; +import { setLocale } from '../i18n/index.js'; +import { + beginDeferredNavigation, + reassertNavigationHandoff, + runExplicitNavigation, +} from '../navigationPolicy.js'; + +const KEYS = [ + 'Tilted view', + 'Straight-down view', + 'North up', + 'Exit cockpit to fly to a camera', + 'Exit cockpit to fly to a location', + 'Exit cockpit to fly to a photo', + 'Exit cockpit to fly to a shared view', + 'Exit cockpit to fly to a {noun}', + 'Go to expanded Radio section', + 'Expand Radio section in Context', + 'Double-click the header to snap the panel back', + 'New scene name', + 'Delete scene "{scene}" and all shots?', + 'Detailed outline unavailable', +]; + +const messages = extractMessages(); + +for (const key of KEYS) { + test(`marked for translation: ${key}`, () => { + assert.ok(messages.has(key), `not marked with t()/msg(): ${key}`); + }); + for (const locale of shippedLocales()) { + test(`${locale} catalog translates: ${key}`, () => { + const value = readCatalog(locale)[key]; + assert.ok(value, `missing in ${locale}.json`); + assert.notEqual(value, key); + }); + } +} + +test('no bare literal reaches showToast in the orientation and navigation code', () => { + for (const file of [ + '../ui/cameraOrientationControls.js', + '../navigationPolicy.js', + '../ui/locationNavigation.js', + '../ui/panelPositionControls.js', + ]) { + const source = readFileSync( + fileURLToPath(new URL(file, import.meta.url)), + 'utf8', + ); + assert.doesNotMatch( + source, + /_?showToast\??\.?\(\s*['"`]/, + `${file} passes a bare literal to showToast`, + ); + } +}); + +test.afterEach(() => setLocale('en')); + +test('the Cockpit refusal keeps its English wording per noun', () => { + setLocale('en'); + const log = []; + const showToast = (text) => log.push(text); + runExplicitNavigation({ cockpitActive: true, noun: 'camera', showToast }); + beginDeferredNavigation({ cockpitActive: true, showToast }); + reassertNavigationHandoff({ + generation: 1, + currentGeneration: 1, + cockpitActive: true, + showToast, + }); + runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast }); + assert.deepEqual(log, [ + 'Exit cockpit to fly to a camera', + 'Exit cockpit to fly to a location', + 'Exit cockpit to fly to a location', + 'Exit cockpit to fly to a contact', + ]); +}); + +for (const locale of ['es', 'ru']) { + test(`${locale}: the Cockpit refusal is translated`, () => { + setLocale(locale); + const log = []; + runExplicitNavigation({ + cockpitActive: true, + noun: 'camera', + showToast: (text) => log.push(text), + }); + assert.equal(log.length, 1); + assert.doesNotMatch(log[0], /Exit cockpit/); + }); +} diff --git a/src/ui/streetLevelMetaLocale.test.mjs b/src/ui/streetLevelMetaLocale.test.mjs new file mode 100644 index 000000000..0109b8172 --- /dev/null +++ b/src/ui/streetLevelMetaLocale.test.mjs @@ -0,0 +1,31 @@ +// The Street Level meta-line test must not depend on the system locale: the +// code formats the count in the app language (formatNumber), so a contributor +// whose terminal sets LANG=de_DE or es_ES must see the same green run as CI's +// C.UTF-8. Runs the presentation test file under three process locales. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const target = path.join(here, 'streetLevelPresentation.test.mjs'); + +for (const locale of ['de_DE.UTF-8', 'es_ES.UTF-8', 'C.UTF-8']) { + test(`streetLevelPresentation tests pass under LC_ALL=${locale}`, () => { + // Drop the node:test child markers so the nested run is a real top-level run. + const env = { ...process.env, LC_ALL: locale, LANG: locale }; + delete env.NODE_TEST_CONTEXT; + delete env.NODE_TEST_WORKER_ID; + const result = spawnSync(process.execPath, ['--test', target], { + encoding: 'utf8', + env, + }); + const failed = result.stdout.match(/^ℹ fail (\d+)/m)?.[1] ?? '?'; + assert.equal( + result.status, + 0, + `exit status ${result.status}, ${failed} failing test(s) under ${locale}\n${result.stdout.slice(-700)}`, + ); + }); +} diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 74c23c6f8..0ddced9ac 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -97,6 +97,35 @@ function presentStatus(state) { : { text: t('OFF'), tone: '', pressed, title }; } +// Provider refusals with a number in them stay English data +// (providers/kartaview/source.js); they go back onto a placeholder here. +const ERROR_PATTERNS = [ + [ + /^KartaView answered HTTP (\d+)$/, + ([, status]) => t('KartaView answered HTTP {status}', { status }), + ], + [ + /^KartaView refused the request \(code (-?\d+)\)$/, + ([, code]) => t('KartaView refused the request (code {code})', { code }), + ], +]; + +/** + * A provider or viewer error in the UI language. Exported for tests. + * @param {string} text English error text from a provider. + * @returns {string} + */ +export function translateStreetLevelError(text) { + if (typeof text !== 'string' || !text) return text; + const known = translateKnown(text); + if (known !== text) return known; + for (const [pattern, format] of ERROR_PATTERNS) { + const match = pattern.exec(text); + if (match) return format(match); + } + return text; +} + /** A chip is lit only while the layer and its provider are both on. */ function presentProviders(state) { const enabled = state.enabled === true; @@ -107,11 +136,11 @@ function presentProviders(state) { ? t('{name} imagery on', { name: provider.name }) : t('{name} imagery off', { name: provider.name }); if (provider.keyRejected && provider.error) - title = `${provider.name}: ${translateKnown(provider.error)}`; + title = `${provider.name}: ${translateStreetLevelError(provider.error)}`; else if (keyRequired && provider.requiresKeyId) title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`; else if (provider.error) - title = `${provider.name}: ${translateKnown(provider.error)}`; + title = `${provider.name}: ${translateStreetLevelError(provider.error)}`; return { id: provider.id, label: translateKnown(provider.label), @@ -262,8 +291,9 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { providers: presentProviders(state), error: keyMissing ? null - : translateKnown(state.street.error || state.coverage.error || '') || - null, + : translateStreetLevelError( + state.street.error || state.coverage.error || '', + ) || null, filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 }, filterControls: { pano: state.filterControls?.pano !== false, diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 5425afd75..ccbfaa687 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -446,9 +446,11 @@ test('the meta line keeps the sequence count and adds the other providers’ hin }, }), ); + // The count follows the app locale (formatNumber under the default 'en'), + // not the system locale of the Node process running the tests. assert.equal( view.meta, - `${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`, + '2,514 sequences in view · click a line for its photos · click a street to open Street View', ); }); diff --git a/src/ui/styles/bhote-koshi.css b/src/ui/styles/bhote-koshi.css index 55e8520bf..21a0348f7 100644 --- a/src/ui/styles/bhote-koshi.css +++ b/src/ui/styles/bhote-koshi.css @@ -281,22 +281,16 @@ border-top: 3px solid #6be8ff; } -/* The title takes the full card width and up to two lines, so a translated - title keeps its place name; the link sits beside the status below it. */ .bhote-embedded-callout footer strong { - grid-column: 1 / -1; - display: -webkit-box; overflow: hidden; font-size: 11px; line-height: 1.25; - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + text-overflow: ellipsis; + white-space: nowrap; } .bhote-embedded-callout footer small { grid-column: 1; - align-self: center; color: rgba(151, 211, 224, 0.7); font-size: 8px; line-height: 1.35; @@ -304,14 +298,34 @@ .bhote-embedded-callout footer a { grid-column: 2; - grid-row: 2; + grid-row: 1 / span 2; align-self: center; - white-space: nowrap; color: #6be8ff; font-size: 8px; text-decoration: none; } +/* The title takes the full card width and up to two lines, so a translated + title keeps its place name; the link sits beside the status below it. */ +html:not([lang='en']) .bhote-embedded-callout footer strong { + grid-column: 1 / -1; + display: -webkit-box; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +html:not([lang='en']) .bhote-embedded-callout footer small { + align-self: center; +} + +html:not([lang='en']) .bhote-embedded-callout footer a { + grid-row: 2; + white-space: nowrap; +} + .bhote-embedded-callout footer a:hover, .bhote-embedded-callout footer a:focus-visible { text-decoration: underline; @@ -423,8 +437,9 @@ html:not([lang='en']) .bhote-event-section-head span:last-child { color: rgba(174, 208, 216, 0.62); } -/* Keep "16 OCT 2021" together so the year never wraps alone. */ -.bhote-event-date { +/* Keep a translated date ("16 OCT 2021") together so the year never wraps + alone. */ +html:not([lang='en']) .bhote-event-date { white-space: nowrap; } @@ -458,7 +473,7 @@ html:not([lang='en']) .bhote-event-section-head span:last-child { align-content: center; column-gap: 9px; min-width: 0; - padding: 5px 10px; + padding: 7px 10px; border: 1px solid rgba(88, 196, 218, 0.18); background: rgba(6, 19, 27, 0.72); } @@ -470,26 +485,45 @@ html:not([lang='en']) .bhote-event-section-head span:last-child { line-height: 1.25; } -/* Up to two lines: a translated beat title keeps its subject. Two title lines - and one meta line still fit the 54px arrow buttons, so the controls stay put. */ .bhote-event-story-current strong { - display: -webkit-box; overflow: hidden; color: var(--text-primary); font-size: 11px; line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bhote-event-story-current small { + grid-column: 1 / -1; + margin-top: 4px; + overflow: hidden; + color: rgba(116, 216, 235, 0.7); + text-overflow: ellipsis; + white-space: nowrap; +} + +html:not([lang='en']) .bhote-event-story-current { + padding: 5px 10px; +} + +/* Up to two lines: a translated beat title keeps its subject. Two title lines + and one meta line still fit the 54px arrow buttons, so the controls stay put. */ +html:not([lang='en']) .bhote-event-story-current strong { + display: -webkit-box; + text-overflow: clip; + white-space: normal; overflow-wrap: break-word; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } /* Phase / time / sources run long in translation: allow a second line. */ -.bhote-event-story-current small { +html:not([lang='en']) .bhote-event-story-current small { display: -webkit-box; - grid-column: 1 / -1; margin-top: 3px; - overflow: hidden; - color: rgba(116, 216, 235, 0.7); + text-overflow: clip; + white-space: normal; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } @@ -514,14 +548,22 @@ html:not([lang='en']) .bhote-event-section-head span:last-child { cursor: pointer; } -/* One line in English; a longer translation wraps to a second line. */ .bhote-event-actions button { min-width: 0; min-height: 30px; - padding: 3px 5px; + padding: 0 5px; overflow: hidden; font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A longer translation wraps to a second line instead of being cut. */ +html:not([lang='en']) .bhote-event-actions button { + padding: 3px 5px; line-height: 1.2; + text-overflow: clip; + white-space: normal; overflow-wrap: break-word; } diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 790885472..5ec14d4f2 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -279,12 +279,12 @@ html[lang='es'] .cctv-controls { animation: cctv-frame-shimmer 1.4s linear infinite; } -/* "Acquiring" caption only when no previous frame is showing underneath */ -.cctv-frame-wrap.loading:not(.has-frame)::before { - content: 'ACQUIRING FRAME'; +/* "Acquiring" caption only when no previous frame is showing underneath. + The text lives in the markup (data-i18n) so it is translated. */ +.cctv-frame-caption { + display: none; position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; font-family: var(--font-mono); @@ -294,6 +294,10 @@ html[lang='es'] .cctv-controls { pointer-events: none; } +.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption { + display: flex; +} + @keyframes cctv-frame-shimmer { to { transform: translateX(100%); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index 3cb833914..c1cd9c562 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -575,7 +575,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) text-align: center; vertical-align: -0.08em; } -.cockpit-roll-token.is-separator { +/* Grouping and decimal separators (a space or comma in Spanish and Russian) + get a narrow cell; English keeps the full 0.56em cell. */ +html:not([lang='en']) .cockpit-roll-token.is-separator { min-width: 0.3em; } .cockpit-roll-token.is-rolling { @@ -1508,9 +1510,11 @@ body.cockpit-mode #view-switcher button { .cockpit-signal-header > div { display: grid; gap: 2px; +} +html:not([lang='en']) .cockpit-signal-header > div { min-width: 0; } -.cockpit-signal-kicker { +html:not([lang='en']) .cockpit-signal-kicker { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1543,7 +1547,7 @@ body.cockpit-mode #view-switcher button { } /* A longer translated state label widens the cycle button; the other controls keep their size instead of being squeezed. */ -.cockpit-brief-actions > * { +html:not([lang='en']) .cockpit-brief-actions > * { flex-shrink: 0; } .cockpit-brief-actions button { @@ -1568,12 +1572,15 @@ body.cockpit-mode #view-switcher button { font-size: 14px; } #cockpit-brief-auto { - width: auto; - min-width: 50px; - padding: 0 4px; + width: 50px; color: rgba(244, 251, 255, 0.42); font: 700 6px/1 var(--font-mono); letter-spacing: 0.05em; +} +html:not([lang='en']) #cockpit-brief-auto { + width: auto; + min-width: 50px; + padding: 0 4px; white-space: nowrap; } #cockpit-brief-auto[aria-pressed='true'] { @@ -1880,18 +1887,24 @@ body.cockpit-mode #view-switcher button { letter-spacing: 0.1em; } .cockpit-signal-footer > span { - flex: 1 1 auto; - min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cockpit-brief-tabs { display: flex; - flex: 0 0 auto; gap: 3px; } -.cockpit-brief-tabs button { +/* Translated tab names are longer: the tabs keep their words whole and the + footer text beside them gives way. */ +html:not([lang='en']) .cockpit-signal-footer > span { + flex: 1 1 auto; + min-width: 0; +} +html:not([lang='en']) .cockpit-brief-tabs { + flex: 0 0 auto; +} +html:not([lang='en']) .cockpit-brief-tabs button { white-space: nowrap; } .cockpit-brief-tabs button { diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index ed68cab1e..cd2f1989d 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -240,8 +240,6 @@ html:not([lang='en']) } .panel-collapse-btn { - /* A long translated title must not squeeze the toggle into a sliver. */ - flex-shrink: 0; width: 22px; height: 22px; border-radius: 6px; @@ -258,6 +256,11 @@ html:not([lang='en']) color var(--transition-fast); } +/* A long translated title must not squeeze the toggle into a sliver. */ +html:not([lang='en']) .panel-collapse-btn { + flex-shrink: 0; +} + .panel-collapse-btn:hover { border-color: var(--glass-border-hover); color: var(--text-primary); @@ -787,8 +790,9 @@ html:not([lang='en']) /* One label column across the Display rows: --pp-label-chars is the longest label of the active language (set by applicationShell), and the labels are monospace, so every slider track starts at the same x whatever the - language. 0.125em is the 1px letter-spacing at 8px. */ -.pp-slider-row > .pp-slider-mini-label { + language. 0.125em is the 1px letter-spacing at 8px. English keeps its + own label widths. */ +html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label { flex: 0 0 auto; min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em)); } @@ -989,9 +993,9 @@ html:not([lang='en']) #draw-mode-row { min-width: 184px; } -/* Equal thirds with room around the label, so a long shape name never runs - edge to edge in its segment. */ -#draw-mode-row .pp-mode-btn { +/* Equal thirds with room around the label, so a long translated shape name + never runs edge to edge in its segment. */ +html:not([lang='en']) #draw-mode-row .pp-mode-btn { padding: 0 4px; } #draw-label-row { @@ -1015,9 +1019,11 @@ html:not([lang='en']) .pp-text-input:focus { border-color: var(--accent); } -/* Sized to its longest colour name so a translated one is never clipped; - 74px keeps the English width. */ .draw-color-select { + flex: 0 0 74px; +} +/* Sized to its longest colour name so a translated one is never clipped. */ +html:not([lang='en']) .draw-color-select { flex: 0 0 auto; min-width: 74px; } @@ -1339,20 +1345,31 @@ body.ui-clean-view #scene-runtime.active { ); } -/* One label column for every parameter, as wide as the longest label in the - active language, so all slider tracks start and end at the same x. */ -#param-sliders { +.param-slider-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.param-slider-row:last-child { + margin-bottom: 0; +} + +/* One label column for every parameter, as wide as the longest translated + label, so all slider tracks start and end at the same x. */ +html:not([lang='en']) #param-sliders { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 10px; } -.param-slider-row { +html:not([lang='en']) .param-slider-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; - align-items: center; + margin-bottom: 0; } .param-label { diff --git a/src/ui/styles/english-css-parity.test.mjs b/src/ui/styles/english-css-parity.test.mjs new file mode 100644 index 000000000..4f24e1177 --- /dev/null +++ b/src/ui/styles/english-css-parity.test.mjs @@ -0,0 +1,212 @@ +// K-44 (+ refront-opus-1): layout rules written for long translations must not change the +// English rendering ("English renders as before"). Every rule below is read +// from the stylesheet without a `html:not([lang='en'])` guard, so it applies +// to English too; the expected values are the ones on main (9beadbda). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const css = (name) => readFileSync(join(here, name), 'utf8'); + +/** Declarations of every rule whose (unguarded) selector list is `selector`. */ +function unguardedRules(source, selector) { + const norm = (s) => s.replace(/\s+/g, ' ').trim(); + const want = norm(selector); + const out = []; + const re = /([^{}]+)\{([^{}]*)\}/g; + let m; + while ((m = re.exec(source))) { + // The captured "selector" may carry the tail of a preceding block + // (comments, an @media prelude); keep what follows the last `}` or `*/`. + const raw = m[1].split(/\*\/|\}/).pop(); + const sel = norm(raw); + if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]); + } + return out; +} + +function declaration(block, prop) { + const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)); + return m ? m[1].replace(/\s+/g, ' ').trim() : undefined; +} + +test('K-44: layer status chip keeps its English width (82px)', () => { + const rules = unguardedRules( + css('radio.css'), + `.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded, + .data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial, + .data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`, + ); + assert.equal(rules.length, 1); + assert.equal(declaration(rules[0], 'min-width'), '82px'); +}); + +test('K-44: Directions steps keep one line with an ellipsis in English', () => { + const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text'); + assert.ok(rule); + assert.equal(declaration(rule, 'white-space'), 'nowrap'); + assert.equal(declaration(rule, 'text-overflow'), 'ellipsis'); +}); + +test('K-44: count-line layout is not reachable from an unguarded selector', () => { + const source = css('radio.css'); + assert.equal( + unguardedRules(source, '.data-toggle-row--count-line .data-count').length, + 0, + ); +}); + +test('K-44: weather status line keeps its fixed English height', () => { + const [rule] = unguardedRules( + css('weather.css'), + ".weather-card [data-line-id='status']", + ); + assert.ok(rule); + assert.equal(declaration(rule, 'height'), '1.45em'); +}); + +test('K-44: cockpit number separators keep the English cell width', () => { + assert.equal( + unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator') + .length, + 0, + ); +}); + +test('K-44: Bhote Koshi callout keeps its English footer layout', () => { + const source = css('bhote-koshi.css'); + const [strong] = unguardedRules( + source, + '.bhote-embedded-callout footer strong', + ); + assert.equal(declaration(strong, 'white-space'), 'nowrap'); + const [link] = unguardedRules(source, '.bhote-embedded-callout footer a'); + assert.equal(declaration(link, 'grid-row'), '1 / span 2'); +}); + +test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => { + assert.equal( + unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn') + .length, + 0, + ); + const [select] = unguardedRules(css('scenes.css'), '#scene-select'); + assert.equal(declaration(select, 'white-space'), undefined); +}); + +test('K-44: Display and parameter rows keep their English layout', () => { + const source = css('controls.css'); + assert.equal( + unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length, + 0, + ); + assert.equal(unguardedRules(source, '#param-sliders').length, 0); + const [row] = unguardedRules(source, '.param-slider-row'); + assert.equal(declaration(row, 'display'), 'flex'); + const [color] = unguardedRules(source, '.draw-color-select'); + assert.equal(declaration(color, 'flex'), '0 0 74px'); +}); + +test('K-44: weather setting rows keep the 56px English label column', () => { + const source = css('weather.css'); + assert.equal(unguardedRules(source, '.rail-card-settings').length, 0); + const [row] = unguardedRules(source, '.rail-card-setting'); + assert.equal( + declaration(row, 'grid-template-columns'), + '56px minmax(0, 1fr)', + ); +}); + +test('K-44: cockpit brief controls keep their English sizes', () => { + const source = css('cockpit.css'); + const [auto] = unguardedRules(source, '#cockpit-brief-auto'); + assert.equal(declaration(auto, 'width'), '50px'); + assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0); +}); + +test('K-44: the count-line class is set only outside English', async () => { + const { setLocale } = await import('../../i18n/index.js'); + const { LayerPanel } = await import('../layerPanel.js'); + const classes = () => { + const toggled = new Map(); + const node = () => ({ + textContent: '', + classList: { toggle: (name, on) => toggled.set(name, on) }, + appendChild() {}, + }); + LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), { + countLabel: '693 nearby · 93 on screen', + }); + return toggled.get('data-toggle-row--count-line'); + }; + globalThis.document ??= { createElement: () => ({}) }; + try { + setLocale('en'); + assert.equal(classes(), false); + setLocale('es'); + assert.equal(classes(), true); + } finally { + setLocale('en'); + } +}); + +test('K-44: Recent Imagery readout keeps its English layout', () => { + const source = css('recent-imagery.css'); + const one = (selector) => { + const rules = unguardedRules(source, selector); + assert.equal(rules.length, 1, selector); + return rules[0]; + }; + // The SHOW chip shares its row with the reserved B half. + assert.equal( + unguardedRules( + source, + ".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']", + ).length, + 0, + ); + assert.equal( + unguardedRules( + source, + ".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']", + ).length, + 0, + ); + // Slot rows: their own fixed 44px tag column, no subgrid. + const selection = one('.recent-imagery-readout .ri-selection'); + assert.equal(declaration(selection, 'grid-template-columns'), undefined); + assert.equal(declaration(selection, 'column-gap'), undefined); + assert.equal( + declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'), + undefined, + ); + const slot = one('.recent-imagery-readout .ri-slot'); + assert.equal( + declaration(slot, 'grid-template-columns'), + '44px minmax(0, 1fr) 22px', + ); + assert.equal(declaration(slot, 'grid-column'), undefined); + assert.equal(declaration(slot, 'height'), '20px'); + assert.equal( + declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'), + undefined, + ); + // Opacity / SWAP / export row: fixed English columns. + assert.equal( + declaration( + one('.recent-imagery-readout .ri-controls'), + 'grid-template-columns', + ), + '76px minmax(0, 1fr) 40px 128px', + ); + const label = one('.recent-imagery-readout .ri-opacity-label'); + assert.equal(declaration(label, 'flex-wrap'), undefined); + assert.equal(declaration(label, 'gap'), '4px'); + assert.equal(declaration(label, 'line-height'), undefined); + const swap = one('.recent-imagery-readout .ri-swap'); + assert.equal(declaration(swap, 'width'), '40px'); + assert.equal(declaration(swap, 'min-width'), undefined); +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5f3e4a02e..568a60a9e 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -250,12 +250,8 @@ gap: 5px; } .radio-transport button { - min-width: 0; min-height: 30px; padding: 6px 3px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; border: 1px solid rgba(65, 220, 231, 0.28); border-radius: 5px; color: #8feaf2; @@ -264,6 +260,13 @@ letter-spacing: 0.06em; cursor: pointer; } +/* A longer translated action stays on one line, cut with an ellipsis. */ +html:not([lang='en']) .radio-transport button { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .radio-transport button:hover:not(:disabled), .radio-transport button:focus-visible:not(:disabled) { border-color: #65f7ff; @@ -614,14 +617,10 @@ gap: 8px; } -/* The name takes the room the status column leaves; a long word wraps inside - it instead of pushing the toggle out of line. */ .data-toggle-left { display: flex; - flex: 1 1 auto; align-items: center; gap: 8px; - min-width: 0; } .data-icon { @@ -629,37 +628,16 @@ line-height: 1; } -/* A fixed icon box, so every name (and the meta line under it, 26px in) - starts at the same x whatever the glyph's width. */ -.data-toggle-left .data-icon { - flex: none; - width: 18px; - text-align: center; -} - .data-name { - min-width: 0; font-family: var(--font-sans); font-size: 13px; font-weight: 500; color: var(--text-primary); letter-spacing: 0.3px; - overflow-wrap: anywhere; -} - -/* Translated layer names are longer: break between words, and hyphenate a - word that still does not fit instead of splitting it anywhere. */ -html:not([lang='en']) .data-name { - overflow-wrap: break-word; - hyphens: auto; - /* No two-letter pieces such as "es-paciales": a word hyphenates only when - at least four letters stay on each side. */ - hyphenate-limit-chars: 8 4 4; } .data-toggle-right { display: flex; - flex: 0 1 auto; align-items: center; gap: 10px; } @@ -669,34 +647,68 @@ html:not([lang='en']) .data-name { font-size: 11px; letter-spacing: 0.5px; color: var(--text-secondary); + min-width: 30px; text-align: right; +} + +/* Longer translations below; English keeps the layout above. */ + +/* The name takes the room the status column leaves; a long word wraps inside + it instead of pushing the toggle out of line. */ +html:not([lang='en']) .data-toggle-left { + flex: 1 1 auto; + min-width: 0; +} + +/* A fixed icon box, so every name (and the meta line under it, 26px in) + starts at the same x whatever the glyph's width. */ +html:not([lang='en']) .data-toggle-left .data-icon { + flex: none; + width: 18px; + text-align: center; +} + +/* Translated layer names are longer: break between words, and hyphenate a + word that still does not fit instead of splitting it anywhere. */ +html:not([lang='en']) .data-name { + min-width: 0; + overflow-wrap: break-word; + hyphens: auto; + /* No two-letter pieces such as "es-paciales": a word hyphenates only when + at least four letters stay on each side. */ + hyphenate-limit-chars: 8 4 4; +} + +html:not([lang='en']) .data-count { + min-width: auto; text-wrap: balance; } /* Row text pieces that must not break inside: a hyphenated code (ADS-B) and the space before a " · " separator (see setRowText in layerPanel.js). */ -.data-keep { +html:not([lang='en']) .data-keep { white-space: nowrap; } /* A count phrase ("24 nearby · 2 on screen") takes its own line under the - name instead of squeezing it beside the toggle. */ -.data-toggle-row--count-line .data-toggle-top { + name instead of squeezing it beside the toggle. layerPanel.js sets the + class only outside English. */ +html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top { flex-wrap: wrap; row-gap: 2px; } -.data-toggle-row--count-line .data-toggle-right { +html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right { display: contents; } /* A zero basis, so a long name wraps beside the toggle instead of pushing the toggle onto the count's line. */ -.data-toggle-row--count-line .data-toggle-left { +html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left { flex-basis: 0; } -.data-toggle-row--count-line .data-count { +html:not([lang='en']) .data-toggle-row--count-line .data-count { order: 1; flex: 1 0 100%; padding-left: 26px; @@ -706,7 +718,8 @@ html:not([lang='en']) .data-name { /* The "—" placeholder gives way to a wide status chip, so the name keeps the room it needs. */ -.data-toggle-right:has( +html:not([lang='en']) + .data-toggle-right:has( .data-toggle-btn:is( .transitioning, .feed-loading, @@ -739,9 +752,12 @@ html:not([lang='en']) .data-name { min-width var(--transition-fast), letter-spacing var(--transition-fast); outline: none; - flex: none; min-width: 38px; text-align: center; +} + +html:not([lang='en']) .data-toggle-btn { + flex: none; white-space: nowrap; } @@ -751,18 +767,30 @@ html:not([lang='en']) .data-name { outline-offset: -3px; } -/* Status chips up to eight characters share one width; a longer word grows - its own chip rather than widening every chip and squeezing every name. */ .data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded, .data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial, .data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable { - min-width: 72px; + min-width: 82px; letter-spacing: 0.8px; } +/* Status chips up to eight characters share one width; a longer word grows + its own chip rather than widening every chip and squeezing every name. */ +html:not([lang='en']) + .data-toggle-btn:is( + .feed-loading, + .feed-degraded, + .feed-stale, + .feed-partial, + .feed-fallback, + .feed-unavailable + ) { + min-width: 72px; +} + .data-toggle-btn:hover { border-color: var(--glass-border-hover); background: rgba(255, 255, 255, 0.06); @@ -964,15 +992,27 @@ html:not([lang='en']) .data-name { .data-row-list-lead { min-width: 34px; flex: none; - white-space: nowrap; opacity: 0.85; } -/* The whole instruction wraps under itself rather than losing its tail: - "Turn right onto …" says nothing, and Russian or Spanish run longer. */ .data-row-list-text { flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A translated instruction wraps under itself rather than losing its tail: + "Gira a la derecha hacia …" says nothing. */ +html:not([lang='en']) .data-row-list-lead { + white-space: nowrap; +} + +html:not([lang='en']) .data-row-list-text { min-width: 0; + overflow: visible; + text-overflow: clip; + white-space: normal; overflow-wrap: anywhere; } diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 11b8bfa1f..78b8822e1 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -293,34 +293,32 @@ height: 18px; font-weight: 700; } -/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long - translation is not cut to a stub. */ -.recent-imagery-readout:not([data-mode='ab']) +/* Outside A / B there is one chip (SHOW): in a translation it takes the + whole row, so a long label is not cut to a stub. English keeps the + reserved B half. */ +html:not([lang='en']) + .recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a'] { grid-column: 1 / -1; } -.recent-imagery-readout:not([data-mode='ab']) +html:not([lang='en']) + .recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b'] { display: none; } -/* 4. The selection bar: mode, then two rows that are always there. The slot - rows share the bar's columns (subgrid), so the tag column fits the longest - tag in either row and both rows stay aligned. */ +/* 4. The selection bar: mode, then two rows that are always there. */ .recent-imagery-readout .ri-selection { display: grid; - grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px; grid-template-rows: 20px 20px 20px; - column-gap: 6px; row-gap: 4px; padding: 5px; border: 1px solid var(--weather-card-border); border-radius: 6px; } .recent-imagery-readout .ri-mode { - grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; @@ -329,16 +327,14 @@ height: 20px; } .recent-imagery-readout .ri-slot { - grid-column: 1 / -1; display: grid; - grid-template-columns: subgrid; + grid-template-columns: 44px minmax(0, 1fr) 22px; align-items: center; column-gap: 6px; height: 20px; } .recent-imagery-readout .ri-slot-tag { height: 16px; - padding: 0 3px; line-height: 16px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 3px; @@ -366,12 +362,26 @@ color: var(--text-secondary); font-style: italic; } -/* A translated slot line (date · source · resolution · state) is longer: +/* In a translation the slot rows share the bar's columns (subgrid), so the + tag column fits the longest tag in either row and both rows stay aligned. + A translated slot line (date · source · resolution · state) is longer: let it take a second line rather than cut off the state at the end. */ +html:not([lang='en']) .recent-imagery-readout .ri-selection { + grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px; + column-gap: 6px; +} +html:not([lang='en']) .recent-imagery-readout .ri-mode { + grid-column: 1 / -1; +} html:not([lang='en']) .recent-imagery-readout .ri-slot { + grid-column: 1 / -1; + grid-template-columns: subgrid; height: auto; min-height: 20px; } +html:not([lang='en']) .recent-imagery-readout .ri-slot-tag { + padding: 0 3px; +} html:not([lang='en']) .recent-imagery-readout .ri-slot-value { white-space: normal; } @@ -383,28 +393,35 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value { } /* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot - while no swipe is live. A label longer than its 76px column widens it to - the label's longest word, and the value wraps under the label. */ + while no swipe is live. */ .recent-imagery-readout .ri-controls { display: grid; - grid-template-columns: - minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content) - 128px; + grid-template-columns: 76px minmax(0, 1fr) 40px 128px; align-items: center; column-gap: 6px; height: 22px; } .recent-imagery-readout .ri-opacity-label { display: flex; - flex-wrap: wrap; justify-content: space-between; - align-content: center; - column-gap: 4px; + gap: 4px; font-size: 8px; - line-height: 10px; letter-spacing: 1px; white-space: nowrap; } +/* In a translation a label longer than its 76px column widens it to the + label's longest word, and the value wraps under the label. */ +html:not([lang='en']) .recent-imagery-readout .ri-controls { + grid-template-columns: + minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content) + 128px; +} +html:not([lang='en']) .recent-imagery-readout .ri-opacity-label { + flex-wrap: wrap; + align-content: center; + row-gap: 0; + line-height: 10px; +} .recent-imagery-readout .ri-opacity-value { font-variant-numeric: tabular-nums; color: var(--text-secondary); @@ -419,9 +436,13 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value { opacity: 0.35; } .recent-imagery-readout .ri-swap { - min-width: 40px; + width: 40px; padding: 0 2px; } +html:not([lang='en']) .recent-imagery-readout .ri-swap { + width: auto; + min-width: 40px; +} .recent-imagery-readout .ri-exports .rail-card-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 8047f66e9..e55fd88c3 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -160,16 +160,19 @@ padding: 6px 8px; font-family: var(--font-sans); font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; outline: none; } +html:not([lang='en']) #scene-select { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* A translated action longer than its button wraps inside the button instead - of running past it. English labels fit on one line, so the English layout - is unchanged. */ -#scene-panel .scene-controls .scene-btn { + of running past it. English keeps its one-line buttons ("EXPORT PRESETS" + included). */ +html:not([lang='en']) #scene-panel .scene-controls .scene-btn { white-space: normal; overflow-wrap: break-word; } diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index b03dfe602..2b3ff7e62 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -123,6 +123,11 @@ text-overflow: ellipsis; white-space: nowrap; } +/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer + than the English one; English keeps its 220px. */ +html:not([lang='en']) #global-loading-detail { + max-width: min(300px, 46vw); +} #global-loading-status:is( [data-state='complete'], [data-state='cancelled'], diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index 0fbc85595..ef3d77055 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -231,6 +231,7 @@ font-family: var(--font-sans); font-size: 0.78rem; font-weight: 600; + overflow-wrap: break-word; } .gev-voice-card-chips { @@ -271,6 +272,8 @@ font-size: 0.66rem; line-height: 1.4; color: rgba(223, 245, 250, 0.82); + /* A whole error line or a long headline wraps inside the card. */ + overflow-wrap: break-word; } .gev-voice-card-referents { diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index 1c6f3da29..827f026d1 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -123,9 +123,10 @@ html:not([lang='en']) #command-dock .gev-voice-cost-value { letter-spacing: 0; } -/* Live hints ("Ask or command", "Requesting microphone") run longer in other - languages; give them a second line from the spare space under the readout - rather than cutting them mid-word. */ +/* Live hints ("Ask or command", "Requesting microphone") and the tier switch + ("СТАНДАРТ — со следующей сессии") run longer in other languages; give them + a second line from the spare space under the readout, without the tracking + that would push the tier line onto a third, rather than cutting them. */ html:not([lang='en']) #command-dock #gev-voice-control:is( @@ -139,6 +140,7 @@ html:not([lang='en']) -webkit-line-clamp: 2; line-clamp: 2; line-height: 1.15; + letter-spacing: 0; white-space: normal; overflow-wrap: anywhere; } diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index d49e18cd3..d632bb1bf 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -147,12 +147,18 @@ .rail-card-line { white-space: pre-line; } -/* One line stays reserved so the card does not jump when a status appears; - a long (or translated) status wraps instead of being cut. */ .weather-card [data-line-id='status'] { - min-height: 1.45em; + height: 1.45em; + overflow-y: auto; color: var(--accent); } +/* One line stays reserved so the card does not jump when a status appears; + a translated status runs longer and wraps instead of being cut. */ +html:not([lang='en']) .weather-card [data-line-id='status'] { + height: auto; + min-height: 1.45em; + overflow-y: visible; +} .rail-card-ramp { position: relative; height: 4px; @@ -259,10 +265,12 @@ body.recording-mode .gev-wind-inspection-marker { top: 8px; left: 0; transform: translateX(-50%); - white-space: nowrap; font: 9px/1.45 var(--font-mono); color: var(--text-dim); } +html:not([lang='en']) .rail-card-zero::after { + white-space: nowrap; +} .weather-observed-group { border: 1px solid var(--weather-card-border); @@ -313,21 +321,24 @@ body.recording-mode .gev-wind-inspection-marker { .rail-card-compact.status { color: var(--accent); } +.rail-card-setting { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + align-items: baseline; + gap: 6px; + margin-top: 6px; +} /* One label column per card, as wide as its longest label: chips line up across rows, and a long translated label never runs under its chips. */ -.rail-card-settings { +html:not([lang='en']) .rail-card-settings { display: grid; grid-template-columns: minmax(56px, max-content) minmax(0, 1fr); column-gap: 6px; } -.rail-card-setting { - display: grid; +html:not([lang='en']) .rail-card-setting { grid-column: 1 / -1; grid-template-columns: minmax(56px, max-content) minmax(0, 1fr); grid-template-columns: subgrid; - align-items: baseline; - gap: 6px; - margin-top: 6px; } .rail-card-setting-label { color: var(--text-dim); diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 54fc8c87f..363cfe0af 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -23,6 +23,7 @@
+ CCTV feed frame
diff --git a/src/ui/tileErrorI18n.test.mjs b/src/ui/tileErrorI18n.test.mjs new file mode 100644 index 000000000..3d713300a --- /dev/null +++ b/src/ui/tileErrorI18n.test.mjs @@ -0,0 +1,106 @@ +// Vector tile and weather image failures reach the DATA LAYERS row and the +// ALPR retry chip in the UI language; the sources keep them in English, and +// English shows them as before. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { createVectorTileSource } from '../sources/vectorTiles.js'; + +globalThis.document ??= { + getElementById: () => null, + addEventListener() {}, + removeEventListener() {}, + hidden: false, +}; +const { LayerPanel } = await import('./layerPanel.js'); +const { localizeLoadingPresentation } = await import('./shellFeedback.js'); + +const row = (id, error) => + LayerPanel.prototype._buildMetaText({ + id, + enabled: true, + stats: { status: 'unavailable', error, source: 'OpenSky' }, + }); +const chipLabel = (label) => + localizeLoadingPresentation({ + state: 'retry', + label, + detail: 'ALPR cameras · retrying in 53s', + }).label; + +test.afterEach(() => setLocale('en')); + +test('the vector tile source fails with the catalog key', async () => { + const source = createVectorTileSource({ + tileJsonUrl: 'https://tiles.example/index.json', + decode: () => [], + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects(source.getMetadata(), { + message: 'Vector tile metadata unavailable', + }); +}); + +test('en: tile and weather image failures read as before', () => { + setLocale('en'); + assert.match( + row('alpr-cameras', 'Vector tile metadata unavailable'), + / · Vector tile metadata unavailable$/, + ); + assert.match( + row('weather-radar', 'Weather image HTTP 404'), + /Weather image HTTP 404$/, + ); + assert.equal( + chipLabel('VECTOR TILE METADATA UNAVAILABLE'), + 'VECTOR TILE METADATA UNAVAILABLE', + ); + assert.equal( + chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'), + 'VECTOR TILES UNAVAILABLE (HTTP 503)', + ); + assert.equal(chipLabel('SOMETHING ELSE'), 'SOMETHING ELSE'); +}); + +const EXPECTED = { + ru: { + metadata: 'Метаданные векторных тайлов недоступны', + http: 'Векторные тайлы недоступны (HTTP 503)', + weather: 'Снимок погоды: HTTP 404', + }, + es: { + metadata: 'Metadatos de teselas vectoriales no disponibles', + http: 'Teselas vectoriales no disponibles (HTTP 503)', + weather: 'Imagen del clima: HTTP 404', + }, +}; + +for (const [locale, expected] of Object.entries(EXPECTED)) { + test(`${locale}: tile failures are translated in the row and the chip`, () => { + setLocale(locale); + assert.match( + row('alpr-cameras', 'Vector tile metadata unavailable'), + new RegExp(` · ${expected.metadata}$`), + ); + assert.ok( + row('alpr-cameras', 'Vector tiles unavailable (HTTP 503)').endsWith( + expected.http, + ), + ); + assert.equal( + chipLabel('VECTOR TILE METADATA UNAVAILABLE'), + expected.metadata.toLocaleUpperCase(locale), + ); + assert.equal( + chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'), + expected.http.toLocaleUpperCase(locale), + ); + }); + + test(`${locale}: a weather image failure is not read as a source name`, () => { + setLocale(locale); + assert.ok( + row('weather-radar', 'Weather image HTTP 404').endsWith(expected.weather), + ); + }); +} diff --git a/src/ui/untranslatedErrors.test.mjs b/src/ui/untranslatedErrors.test.mjs new file mode 100644 index 000000000..68f6cef14 --- /dev/null +++ b/src/ui/untranslatedErrors.test.mjs @@ -0,0 +1,105 @@ +// K-38: refusals and failures that source modules produce in English reach +// the page translated — through a placeholder at the point of display, or +// through a catalog entry when the text is stored as data. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale, t, translateKnown } from '../i18n/index.js'; +import { presentStreetLevelPanel } from './streetLevelPresentation.js'; +import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs'; +import { createWeatherSource } from '../layers/weather/source.js'; +import { + presentRoutingFailure, + presentRoutingRefusal, +} from '../layers/directions/index.js'; +import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js'; + +const NO_IMAGERY = 'No street-level imagery within 50 m of that point'; + +function snapshot(error) { + return { + enabled: true, + keyRequired: false, + filter: { pano: 'all', sinceDays: 0 }, + providers: [providerSnapshot()], + coverage: { loading: false, count: 0, hint: '', error: null }, + legend: [], + sequence: { providerId: null, selectedId: null, images: 0, loading: false }, + street: { open: false, loading: false, error, providerId: null }, + groundClickReady: true, + }; +} + +for (const locale of ['es', 'ru']) { + test(`K-38 (${locale}): the nearest-image refusal is built in the language and kept by the panel`, () => { + setLocale(locale); + try { + // What streetLevel/index.js openNearest writes into street.error. + const refusal = t( + 'No street-level imagery within {radius} m of that point', + { radius: 50 }, + ); + assert.notEqual(refusal, NO_IMAGERY, 'should be translated'); + assert.match(refusal, /50/, 'keeps the radius'); + assert.equal(presentStreetLevelPanel(snapshot(refusal)).error, refusal); + // The screen-centre refusal is stored as English data and translated + // by the panel. + const aim = presentStreetLevelPanel(snapshot(AIM_AT_GROUND_MESSAGE)); + assert.notEqual(aim.error, AIM_AT_GROUND_MESSAGE, 'should be translated'); + } finally { + setLocale('en'); + } + }); + + test(`K-38 (${locale}): the route proxy's refusals are shown translated`, () => { + setLocale(locale); + try { + const shown = t('Routing failed: {error}', { + error: presentRoutingRefusal('route leg too long'), + }); + assert.doesNotMatch(shown, /route leg too long/); + const limited = presentRoutingFailure( + Object.assign(new Error('rate limited — try again in a moment'), { + status: 429, + refusal: 'rate limited', + }), + ); + assert.doesNotMatch(limited, /rate limited|try again in a moment/); + const http = presentRoutingFailure( + Object.assign(new Error('Routing unavailable (HTTP 503)'), { + status: 503, + }), + ); + assert.notEqual(http, 'Routing unavailable (HTTP 503)'); + assert.match(http, /503/); + // A text the proxy never sends is shown as given. + assert.equal(presentRoutingRefusal('something else'), 'something else'); + } finally { + setLocale('en'); + } + }); + + test(`K-38 (${locale}): a weather manifest timeout is shown translated`, async () => { + const source = createWeatherSource({ + fetchImpl: (_url, { signal }) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ), + timeoutMs: 10, + }); + let message = null; + try { + await source.getSnapshot({ product: 'radar' }); + } catch (error) { + message = error.message; + } + assert.equal(message, 'Weather request timed out', 'stored in English'); + setLocale(locale); + try { + // The layer shows `t(problem)` (weather/index.js presentWeatherProblem). + assert.notEqual(t(message), message, 'should be translated'); + assert.notEqual(translateKnown(message), message); + } finally { + setLocale('en'); + } + }); +} diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 666edbf8a..f6a1b6cef 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -1,6 +1,6 @@ import { formatUtcDayMonth, t } from '../i18n/index.js'; import { createRailCards } from './railCards.js'; -import { createRailTimeline } from './railTimeline.js'; +import { createRailTimeline, utcClock } from './railTimeline.js'; // Remounting controls must not reopen a panel the user already collapsed. const appearedDocuments = new WeakSet(); @@ -15,8 +15,9 @@ const ORDER = [ const OBSERVED = new Set(ORDER.slice(2)); const utc = (time) => Number.isFinite(Date.parse(time)) - ? `${formatUtcDayMonth(time)} ${new Date(time).toISOString().slice(11, 16)} UTC` + ? `${formatUtcDayMonth(time)} ${utcClock(time)}` : t('Unavailable'); +const clockTime = (time) => utcClock(time) || t('Unavailable'); const age = (time) => { const minutes = Math.max( 0, @@ -31,7 +32,7 @@ const age = (time) => { }; const historyTime = (time) => t('{time} · {minutes} min ago', { - time: utc(time).slice(6), + time: clockTime(time), minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)), }); const dated = (time) => `${utc(time)} · ${age(time)}`; @@ -172,7 +173,7 @@ export function createWeatherPanel({ const shown = product?.shown ?? summary.shownTime; if (state.mode === 'history' && product?.selected === null) { const gap = summary.maxGapMinutes || 30; - const time = utc(state.target).slice(6); + const time = clockTime(state.target); detail = gap < 60 ? t('No frame within {gap} min of {time}', { gap, time }) diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index 20d463229..79f6802b5 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; import { createWeatherPanel } from './weatherPanel.js'; import { railFixture } from './railTestFixture.mjs'; const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z']; @@ -654,3 +655,34 @@ test('a change of open card scrolls its header into view once; refreshes never m assert.deepEqual(intoView, [], 'ancestor scrollers are never moved'); view.destroy(); }); +test('history time keeps the hour whatever the length of the localized date (K-6)', () => { + for (const locale of ['en', 'es', 'ru']) { + setLocale(locale); + try { + const f = fixture(); + const view = createWeatherPanel(f); + view.update([wind, radar]); + const ends = f.find((n) => n.className === 'rail-timeline-endpoints'); + assert.deepEqual( + ends.children.map((n) => n.textContent), + ['01:00 UTC', '01:05 UTC'], + locale, + ); + f.state({ mode: 'history', target: ticks[1] }); + assert.match( + line(f, radar.id, 'time').textContent, + /(^|[^\d:])01:05 UTC/, + locale, + ); + f.state({ products: [{ id: radar.id, shown: null, selected: null }] }); + assert.match( + line(f, radar.id, 'time').textContent, + /(^|[^\d:])01:05 UTC/, + locale, + ); + view.destroy(); + } finally { + setLocale('en'); + } + } +}); diff --git a/src/voice/analystCardLocale.test.mjs b/src/voice/analystCardLocale.test.mjs new file mode 100644 index 000000000..be142ab5f --- /dev/null +++ b/src/voice/analystCardLocale.test.mjs @@ -0,0 +1,102 @@ +// K-8: the analyst card shows its headline, scope, chips and caveats in the +// interface language; the spoken headline stays English for the model. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../i18n/index.js'; +import { attachVoiceResult as withSpeech } from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; + +function cardFor(name, result, args = {}) { + const shaped = withSpeech(name, result, args); + return { + shaped, + view: voiceCardView( + [ + { type: 'action-call', callId: 'c1', name, arguments: args }, + { type: 'action-result', callId: 'c1', name, result: shaped }, + ].reduce(reduceVoiceCard, initialVoiceCardState()), + ), + }; +} + +const analyst = { + ok: true, + count: 3, + complete: true, + scopeLabel: 'anywhere in the loaded data', + partial: true, + feedState: 'degraded', + unanswered: ['earthquakes'], + coverage: { layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }] }, + display: { scope: 'anywhere in the loaded data' }, + items: [], +}; + +const radius = { + ...analyst, + count: 1234, + complete: false, + partial: false, + feedState: 'nominal', + unanswered: [], + scopeLabel: 'in view', + display: { + scope: 'within 42 km of the view centre', + window: 'last 24 h', + caveat: + 'counted 1,000 of 2,345 loaded records; earthquakes loading; flights: distance is ground distance, not overhead', + }, + coverage: { + layersQueried: [{ layerKey: 'flights', source: 'OpenSky' }], + note: 'the flights layer loads by viewport', + }, +}; + +test('K-8: analyst card in es has no English text', () => { + setLocale('es'); + try { + const { view } = cardFor('analyst_query', analyst); + assert.doesNotMatch(view.result.title, /anywhere in the loaded data/); + assert.ok(!view.result.chips.includes('partial')); + assert.ok(!view.result.chips.includes('degraded')); + assert.ok(!view.result.notes.some((n) => n.startsWith('Not answered'))); + } finally { + setLocale('en'); + } +}); + +test('K-8: analyst scope, window and caveats in ru', () => { + setLocale('ru'); + try { + const { shaped, view } = cardFor('analyst_query', radius); + assert.match(shaped.say, /^At least 1,234 aircraft in view/); + assert.doesNotMatch( + JSON.stringify(view.result), + /aircraft|within|view centre|last 24 h|counted|loading|ground distance|loads by viewport/, + ); + assert.match(view.result.title, /^1\s234 /); + } finally { + setLocale('en'); + } +}); + +test('K-8: the English card reads as before, layers by name', () => { + const { shaped, view } = cardFor('analyst_query', analyst); + assert.equal(view.result.title, '3 aircraft anywhere in the loaded data'); + assert.deepEqual(view.result.chips, ['degraded', 'partial']); + assert.ok(view.result.notes.includes('Not answered: Earthquakes (24h)')); + // The model still gets the layer key. + assert.match(shaped.say, /earthquakes not answered/); + const capped = cardFor('analyst_query', radius).view.result; + assert.equal(capped.title, 'At least 1,234 aircraft in view'); + assert.deepEqual(capped.lines, [ + 'within 42 km of the view centre', + 'last 24 h', + ]); + assert.ok(capped.notes.includes('counted 1,000 of 2,345 loaded records')); + assert.ok(capped.notes.includes('the flights layer loads by viewport')); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 6caff8ef9..12eda3b0a 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -8,6 +8,7 @@ import { SPEECH_BUILDERS, attachVoiceResult } from './speech.js'; import { resolveDeicticArgs } from './deixis.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; +import { msg } from '../i18n/index.js'; import * as Cesium from 'cesium'; import { CITY_POIS, @@ -1442,7 +1443,7 @@ function controlScene(sceneDirector, args = {}) { }; } if (sceneAction === 'stop') { - sceneDirector.stopScene('Stopped by voice'); + sceneDirector.stopScene(msg('Stopped by voice')); return { ok: true, action: 'control_scene', running: false }; } if (sceneAction === 'next') { diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 6edc604ef..01c50379e 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -1079,7 +1079,9 @@ test('generic layer visibility exposes lifecycle truth for every manager phase a }); // The voice envelope is additive and states the failure plainly. assert.equal(say, "Couldn't turn on radio."); - assert.equal(display.title, 'radio'); + // The card names the layer, not its id; the result keeps the id. + assert.equal(display.title, 'Radio'); + assert.deepEqual(display.lines, ['Radio layer unavailable']); }); test('generic voice visibility maps Space Missions to the explicit mission layer', async () => { diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index aed6920ea..13a9932ed 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -124,7 +124,8 @@ test('a partial answer names the layers it could not read', async () => { assert.match(result.say, /Partial; earthquakes not answered/); assert.deepEqual(result.unanswered, ['earthquakes']); assert.ok(view.chips.includes('partial')); - assert.ok(view.notes.includes('Not answered: earthquakes')); + // The card names the layer; the result keeps its key for the model. + assert.ok(view.notes.includes('Not answered: Earthquakes (24h)')); }); test('"the second one" is the second row the card lists', async () => { diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 709e37b99..98b03781f 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -105,7 +105,15 @@ export class RealtimeCost { warn: formatCostUsd(state.warnUsd), cap: formatCostUsd(state.capUsd), }, - ) + (state.note ? ` ${state.note}` : ''); + ) + + // `state.note` is the tracker's English diagnostic; the tooltip says + // the same in the interface language. + (state.incomplete + ? ' ' + + t( + 'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.', + ) + : ''); } } @@ -146,12 +154,12 @@ export class RealtimeCost { } this.syncCostUi(); if (this.isActive() && this.ui?.detail) { - this.setStatus( - this.status, - t('{tier} applies next session', { - tier: this.voiceTier.toUpperCase(), - }), - ); + // The tier as the interface names it; the stored value stays English. + const tier = + resolveVoiceModel(this.voiceTier).tier === 'mini' + ? t('MINI') + : t('STANDARD'); + this.setStatus(this.status, t('{tier} applies next session', { tier })); } return this.voiceTier; } diff --git a/src/voice/realtimeCostIncompleteNote.i18n.test.mjs b/src/voice/realtimeCostIncompleteNote.i18n.test.mjs new file mode 100644 index 000000000..aa51e621a --- /dev/null +++ b/src/voice/realtimeCostIncompleteNote.i18n.test.mjs @@ -0,0 +1,54 @@ +// K-10: the API-key cost tooltip states an incomplete estimate in the +// interface language; the tracker keeps its English note for diagnostics. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { RealtimeCost } from './realtimeCost.js'; +import { createVoiceCostTracker } from './voiceCost.js'; + +for (const locale of ['es', 'ru']) { + test(`API-key cost tooltip for an incomplete session has no English (${locale})`, () => { + setLocale(locale); + try { + const costTracker = createVoiceCostTracker({ tier: 'standard' }); + costTracker.markIncomplete(); + const host = { + costTracker, + voiceTier: 'standard', + cloudVoiceAuth: 'api-key', + sessionCloudVoiceAuth: 'api-key', + isActive: () => false, + ui: { + costValue: { hidden: true, dataset: {}, title: '', textContent: '' }, + }, + }; + RealtimeCost.prototype.syncCostUi.call(host); + const title = host.ui.costValue.title; + assert.ok(title.length > 0); + assert.doesNotMatch( + title, + /Estimate is incomplete|still in flight/, + title, + ); + } finally { + setLocale('en'); + } + }); +} + +test('API-key cost tooltip in English still ends with the tracker note', () => { + const costTracker = createVoiceCostTracker({ tier: 'standard' }); + costTracker.markIncomplete(); + const host = { + costTracker, + voiceTier: 'standard', + cloudVoiceAuth: 'api-key', + sessionCloudVoiceAuth: 'api-key', + isActive: () => false, + ui: { + costValue: { hidden: true, dataset: {}, title: '', textContent: '' }, + }, + }; + RealtimeCost.prototype.syncCostUi.call(host); + assert.ok(host.ui.costValue.title.endsWith(` ${costTracker.state().note}`)); +}); diff --git a/src/voice/realtimeCostTierStatus.test.mjs b/src/voice/realtimeCostTierStatus.test.mjs new file mode 100644 index 000000000..f4e7dc763 --- /dev/null +++ b/src/voice/realtimeCostTierStatus.test.mjs @@ -0,0 +1,50 @@ +// K-11: the status after a tier switch names the tier in the interface +// language, as the tier button does; the stored tier value stays English. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t } from '../i18n/index.js'; +import { RealtimeCost } from './realtimeCost.js'; + +function harness() { + const button = { textContent: '', title: '', setAttribute() {} }; + const ui = { tierButton: button, detail: {} }; + const statusCalls = []; + const cost = new RealtimeCost({ + readUi: () => ui, + readStatus: () => 'listening', + operations: { + isActive: () => true, + isVoiceSessionSettled: () => false, + setStatus: (status, detail) => statusCalls.push(detail), + }, + }); + return { cost, button, statusCalls }; +} + +const EXPECTED = { + en: { + mini: 'MINI applies next session', + standard: 'STANDARD applies next session', + }, + ru: { + mini: 'МИНИ — со следующей сессии', + standard: 'СТАНДАРТ — со следующей сессии', + }, + es: { + mini: 'MINI desde la próxima sesión', + standard: 'ESTÁNDAR desde la próxima sesión', + }, +}; + +for (const locale of Object.keys(EXPECTED)) { + test(`${locale}: tier status names the tier in the interface language`, (ctx) => { + setLocale(locale); + ctx.after(() => setLocale('en')); + for (const tier of ['mini', 'standard']) { + const { cost, button, statusCalls } = harness(); + assert.equal(cost.setVoiceTier(tier), tier, 'stored tier stays English'); + assert.equal(statusCalls.at(-1), EXPECTED[locale][tier]); + assert.equal(button.textContent, tier === 'mini' ? t('MINI') : t('STD')); + } + }); +} diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 7d364de79..427341a02 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -9,11 +9,26 @@ * referents the card lists (the referent registry reads the same answer, so * "the second one" always means the second row on screen). * + * What the model receives stays English (`analystHeadline` feeds the spoken + * answer); the card shows the same answer in the active language. + * * Pure: no DOM, no Cesium. * @module voice/resultDisplay */ import { voiceLayer } from './layerManifest.js'; +import { + formatDate, + formatNumber, + getLocale, + msg, + t, + tn, + translateKnown, +} from '../i18n/index.js'; +import { layerNameForId, sourceLabel } from '../ui/layerNames.js'; +import { localizePlaceName } from '../ui/placeNames.js'; +import { formatQuantity } from '../ui/unitLabels.js'; const LAYER_NOUNS = Object.freeze({ flights: 'aircraft', @@ -52,13 +67,17 @@ export function analystNounFor(layerKeys) { /** The count/scope headline shared by the analyst card and spoken answer. */ const ANALYST_HEADLINE_MAX = 64; +// The card's headline wraps, and a translation runs longer than the English: +// "Al menos 12 aeronaves militares en un radio de 40 km de Los Ángeles" is 67. +// English keeps the spoken bound, so its card title still equals `say`. +const SHOWN_HEADLINE_MAX = 120; -function boundedHeadline(value) { +function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) { const text = String(value || '') .replace(/\s+/g, ' ') .trim(); - if (text.length <= ANALYST_HEADLINE_MAX) return text; - return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`; + if (text.length <= max) return text; + return `${text.slice(0, max - 1).trimEnd()}…`; } export function analystHeadline(result) { @@ -72,6 +91,282 @@ export function analystHeadline(result) { ); } +/* ---------------- the analyst card in the active language ---------------- */ + +/** + * The count and its noun as the card shows them. The English matches + * `analystNounFor`, one form for any count as the spoken headline has it. + */ +function shownCount(count, noun) { + const values = { n: formatNumber(count) }; + switch (noun) { + case 'aircraft': + return tn(count, '{n} aircraft', '{n} aircraft', values); + case 'military aircraft': + return tn( + count, + '{n} military aircraft', + '{n} military aircraft', + values, + ); + case 'ships': + return tn(count, '{n} ships', '{n} ships', values); + case 'satellites': + return tn(count, '{n} satellites', '{n} satellites', values); + case 'launches': + return tn(count, '{n} launches', '{n} launches', values); + case 'earthquakes': + return tn(count, '{n} earthquakes', '{n} earthquakes', values); + case 'fires': + return tn(count, '{n} fires', '{n} fires', values); + case 'fire perimeters': + return tn(count, '{n} fire perimeters', '{n} fire perimeters', values); + case 'cyclones': + return tn(count, '{n} cyclones', '{n} cyclones', values); + case 'buses': + return tn(count, '{n} buses', '{n} buses', values); + case 'bike share': + return tn(count, '{n} bike share', '{n} bike share', values); + case 'flock cameras': + return tn(count, '{n} flock cameras', '{n} flock cameras', values); + case 'military bases': + return tn(count, '{n} military bases', '{n} military bases', values); + case 'datacenters': + return tn(count, '{n} datacenters', '{n} datacenters', values); + case 'dams': + return tn(count, '{n} dams', '{n} dams', values); + case 'results': + return tn(count, '{n} results', '{n} results', values); + default: + return `${values.n} ${noun}`; + } +} + +const shownRadius = (km) => formatQuantity(Number(km), 'km'); + +const FIXED_SCOPES = new Map( + [msg('anywhere in the loaded data'), msg('in view')].map((text) => [ + text, + text, + ]), +); + +/** + * The engine's scope phrases (analystEngine, the Contacts window in + * gevActions) as the card shows them. The English is exactly the engine's; + * a phrase not listed shows as given. + */ +const SCOPE_PATTERNS = [ + [ + /^within (\d+(?:\.\d+)?) km of the view centre$/, + ([, km]) => + t('within {radius} of the view centre', { radius: shownRadius(km) }), + ], + [ + /^within (\d+(?:\.\d+)?) km of (.+) \(Contacts window\)$/, + ([, km, place]) => + t('within {radius} of {place} (Contacts window)', { + radius: shownRadius(km), + place: localizePlaceName(place), + }), + ], + [ + /^within (\d+(?:\.\d+)?) km of (.+)$/, + ([, km, place]) => + t('within {radius} of {place}', { + radius: shownRadius(km), + place: localizePlaceName(place), + }), + ], + [ + /^within (\d+(?:\.\d+)?) km$/, + ([, km]) => t('within {radius}', { radius: shownRadius(km) }), + ], + [/^over (.+)$/, ([, region]) => t('over {region}', { region })], +]; + +function shownScope(text) { + const scope = String(text || '').trim(); + if (!scope) return ''; + if (FIXED_SCOPES.has(scope)) return t(FIXED_SCOPES.get(scope)); + for (const [pattern, show] of SCOPE_PATTERNS) { + const match = pattern.exec(scope); + if (match) return show(match); + } + return translateKnown(scope); +} + +/** The analyst headline in the active language, for the card. */ +function shownAnalystHeadline(result) { + const keys = (result?.coverage?.layersQueried || []).map( + (layer) => layer.layerKey, + ); + const count = Number(result?.count) || 0; + const counted = shownCount(count, analystNounFor(keys)); + const items = + result?.complete === false + ? t('At least {items}', { items: counted }) + : counted; + const scope = shownScope(result?.scopeLabel); + return boundedHeadline( + scope ? t('{items} {scope}', { items, scope }) : items, + getLocale() === 'en' ? ANALYST_HEADLINE_MAX : SHOWN_HEADLINE_MAX, + ); +} + +/** A layer key as the card names it. */ +function shownLayerKey(key) { + const text = String(key || '').trim(); + return layerNameForId(text) || text; +} + +const shownLayerKeys = (list) => + String(list || '') + .split(/,\s*/) + .map(shownLayerKey) + .filter(Boolean) + .join(', '); + +/** The feed windows layers declare in the voice manifest. */ +const FIXED_WINDOWS = new Map( + [msg('last 24 h'), msg('past 30 d and upcoming')].map((text) => [text, text]), +); + +function shownWindow(text) { + return String(text || '') + .split(/;\s*/) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => + FIXED_WINDOWS.has(part) + ? t(FIXED_WINDOWS.get(part)) + : translateKnown(part), + ) + .join('; '); +} + +const UTC_CLOCK = Object.freeze({ + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZone: 'UTC', +}); +const UTC_DATE = Object.freeze({ + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', +}); + +/** + * The snapshot time the note carries (ISO, as the model got it) as the card + * shows it: "14:32:05 UTC", with the date first ("15.10.2026, 14:32:05 UTC") + * when the snapshot is not from today (UTC). English shows the ISO time as + * before; a value that is not a time shows as written. + */ +export function shownSnapshotTime(iso, now = Date.now()) { + const time = Date.parse(iso); + if (getLocale() === 'en' || !Number.isFinite(time)) return iso; + const clock = `${formatDate(time, UTC_CLOCK)} UTC`; + const today = new Date(now).toISOString().slice(0, 10); + return new Date(time).toISOString().slice(0, 10) === today + ? clock + : `${formatDate(time, UTC_DATE)}, ${clock}`; +} + +/** Coverage notes and caveats the engine and the action runner write. */ +const FIXED_NOTES = new Map( + [ + msg('the flights layer loads by viewport'), + msg('distance is ground distance, not overhead'), + msg( + 'the count is a lower bound because at least one layer reached its retained limit.', + ), + ].map((text) => [text, text]), +); + +const LAYER_STATUS_NOTES = Object.freeze({ + off: (label) => t('{label} off', { label }), + loading: (label) => t('{label} loading', { label }), + error: (label) => t('{label} error', { label }), +}); + +const NOTE_PATTERNS = [ + [ + /^counted the first ([\d,]+) loaded records$/, + ([, returned]) => + t('counted the first {returned} loaded records', { + returned: formatNumber(Number(returned.replace(/,/g, ''))), + }), + ], + [ + /^counted ([\d,]+) of ([\d,]+) loaded records$/, + ([, returned, total]) => + t('counted {returned} of {total} loaded records', { + returned: formatNumber(Number(returned.replace(/,/g, ''))), + total: formatNumber(Number(total.replace(/,/g, ''))), + }), + ], + [ + /^(.+) just turned on — still loading, counts will rise$/, + ([, layers]) => + t('{layers} just turned on — still loading, counts will rise', { + layers: shownLayerKeys(layers), + }), + ], + [ + // The note ends in a period unless a lower-bound clause follows it. + /^Contacts panel snapshot(?: at (\S+))? — the same retained cohort supplies the card, speech, and follow-up memory(\.?)$/, + ([, time, period]) => + (time + ? t( + 'Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory', + { time: shownSnapshotTime(time) }, + ) + : t( + 'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory', + )) + period, + ], + [ + /^([a-z0-9-]+) (off|loading|error)$/, + ([, key, status]) => + layerNameForId(key) + ? LAYER_STATUS_NOTES[status](shownLayerKey(key)) + : null, + ], + [ + /^([a-z0-9-]+): (.+)$/, + ([, key, note]) => + layerNameForId(key) ? `${shownLayerKey(key)}: ${shownNote(note)}` : null, + ], +]; + +function shownNote(text) { + if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text)); + for (const [pattern, show] of NOTE_PATTERNS) { + const match = pattern.exec(text); + const shown = match ? show(match) : null; + if (shown) return shown; + } + return translateKnown(text); +} + +/** Feed states as the card's chips show them; the values stay English. */ +const FEED_STATE_LABELS = Object.freeze({ + loading: msg('loading'), + degraded: msg('degraded'), + stale: msg('stale'), + partial: msg('partial'), + fallback: msg('fallback'), + unavailable: msg('unavailable'), +}); + +function shownFeedState(state) { + const key = FEED_STATE_LABELS[state]; + return key ? t(key) : state; +} + function analystCard(result) { if (!result?.ok) return null; const display = result.display || {}; @@ -91,28 +386,32 @@ function analystCard(result) { ].map((label) => ({ label })); return { display: { - title: analystHeadline(result), + title: shownAnalystHeadline(result), // The precise scope, unless the title already says exactly that. lines: [ - display.scope !== result.scopeLabel ? display.scope : null, - display.window, + display.scope !== result.scopeLabel ? shownScope(display.scope) : null, + shownWindow(display.window), ].filter(Boolean), chips: [ ...(result.feedState && result.feedState !== 'nominal' - ? [{ label: result.feedState }] + ? [{ label: shownFeedState(result.feedState) }] : []), - ...(result.partial ? [{ label: 'partial' }] : []), + ...(result.partial ? [{ label: t('partial') }] : []), ], // Coverage qualifications lead the feed detail. The bounded card groups // overflow in its existing Notes disclosure rather than dropping them. preserveNotes: true, notes: [ - ...notesOf(result.coverage?.note, display.caveat), + ...notesOf(result.coverage?.note, display.caveat).map(shownNote), ...(unanswered.length - ? [`Not answered: ${unanswered.join(', ')}`] + ? [ + t('Not answered: {layers}', { + layers: unanswered.map(shownLayerKey).join(', '), + }), + ] : []), ], - sources, + sources: sources.map(({ label }) => ({ label: sourceLabel(label) })), }, referents: Array.isArray(result.items) ? result.items : [], resultSet: true, diff --git a/src/voice/session.test.mjs b/src/voice/session.test.mjs index bdb45e145..4d3fb6940 100644 --- a/src/voice/session.test.mjs +++ b/src/voice/session.test.mjs @@ -8,6 +8,7 @@ import { import { createVoiceSession } from './session.js'; import { realtimeSessionEvent } from './realtimeEvents.js'; import { createVoiceCommands } from './sessionCommands.js'; +import { describeUnsavedWork } from '../i18n/index.js'; function deferred() { let resolve; @@ -300,17 +301,24 @@ test('common button and annotation bindings work with an alternate adapter and c assert.equal(ui.helpDetail.textContent, 'Activate to toggle voice'); assert.equal(ui.status.textContent, 'LISTENING'); assert.equal(ui.detail.textContent, 'Ready'); + // K-27: switching language (a reload) asks first during a session. + assert.deepEqual(describeUnsavedWork(), ['the active voice session']); subscribed({ status: 'resolved' }); assert.deepEqual(events, [ { type: 'map_annotation_outline', status: 'resolved' }, ]); button.dispatchEvent(new Event('click')); assert.equal(controls.state, 'idle'); + assert.deepEqual(describeUnsavedWork(), []); + button.dispatchEvent(new Event('click')); + await new Promise((done) => setTimeout(done, 0)); + assert.deepEqual(describeUnsavedWork(), ['the active voice session']); lifetime.abort(); + assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters'); assert.equal(subscribed, null); assert.equal(removed, 1); button.dispatchEvent(new Event('click')); - assert.equal(calls, 1); + assert.equal(calls, 2); } finally { globalThis.window = previous; } diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index fa7b72def..2d28bb182 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -2,7 +2,7 @@ import { createVoiceControl, voiceStatusLabel } from './control.js'; import { createVoiceSession } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; -import { t } from '../i18n/index.js'; +import { msg, registerUnsavedWork, t } from '../i18n/index.js'; function createDefaultReticle(pointer) { const element = pointer?.element; @@ -107,6 +107,10 @@ export function createVoiceCommands({ card?.outline(event); session.sendMapEvent({ type: 'map_annotation_outline', ...event }); }); + // A reload (the language switch) ends the conversation: ask first. + const unregisterUnsavedWork = registerUnsavedWork(() => + session.isActive() ? msg('the active voice session') : null, + ); const buttonHandler = () => { if (adapter.ignoreButtonClick?.()) return; if (session.isActive()) session.stop(); @@ -117,6 +121,7 @@ export function createVoiceCommands({ 'abort', () => { ui.button.removeEventListener('click', buttonHandler); + unregisterUnsavedWork(); annotationUnsubscribe?.(); updateStatus(); updateCard?.(); @@ -130,6 +135,7 @@ export function createVoiceCommands({ ); if (session.disposed) { ui.button.removeEventListener('click', buttonHandler); + unregisterUnsavedWork(); annotationUnsubscribe?.(); updateStatus(); updateCard?.(); diff --git a/src/voice/speech.js b/src/voice/speech.js index 2813507d9..c37989acb 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -19,11 +19,27 @@ * * Only `display` and the plan/progress labels are interface text, translated * with t(). `say`, `identityLine` and the progress lines are not shown: they go - * to the voice model to be spoken, so they stay as written. + * to the voice model to be spoken, so they stay as written. The tool result + * itself (layer names, sources, `error`) stays English as the model got it; + * the card shows those through the shown-name helpers and `shownError`. */ import { analystHeadline } from './resultDisplay.js'; -import { formatNumber, msg, t, tn } from '../i18n/index.js'; +import { + formatNumber, + getLocale, + msg, + t, + tn, + translateKnown, +} from '../i18n/index.js'; +import { + layerNameForId, + shownLayerName, + sourceLabel, +} from '../ui/layerNames.js'; +import { localizePlaceName } from '../ui/placeNames.js'; +import { formatQuantity } from '../ui/unitLabels.js'; const LAYER_NOUNS = Object.freeze({ flights: 'aircraft', @@ -78,10 +94,150 @@ function shownFeet(meters) { return t('{value} feet', { value: formatNumber(rounded) }); } -function kilometers(km) { +function roundedKm(km) { const value = Number(km); if (!Number.isFinite(value)) return null; - return `${value < 10 ? Number(value.toFixed(1)) : Math.round(value)} km`; + return value < 10 ? Number(value.toFixed(1)) : Math.round(value); +} + +function kilometers(km) { + const value = roundedKm(km); + return value === null ? null : `${value} km`; +} + +/** The card's distance, in the active language: "2,5 км". */ +function shownKm(km) { + const value = roundedKm(km); + return value === null ? null : formatQuantity(value, 'km'); +} + +/** Sources as the card lists them. */ +function shownSources(source) { + return source ? [{ label: sourceLabel(source) }] : []; +} + +/* + * Tool errors stay English in the result: the model reads them. The card shows + * the ones it knows in the interface language. Each English text here is + * exactly what gevActions returns, so English shows as before; an error not + * listed shows as given. + */ +const KNOWN_ERRORS = new Map( + [ + msg('The flights layer is not enabled'), + msg('The military layer is not enabled'), + msg('The satellites layer is not enabled'), + msg('The ais-live-vessels layer is not enabled'), + msg('Could not enable the requested layer'), + msg('Could not disable the requested layer'), + msg('Radio layer unavailable'), + msg('Camera navigation is unavailable in the current view'), + msg('Camera navigation policy unavailable'), + msg('Nearest-aircraft selection supports Flights or Military Flights only'), + msg( + 'Nearest-aircraft selection needs a preset, place name, or latitude and longitude', + ), + msg('Could not arrive at the requested place'), + msg('The nearest airborne aircraft could not be selected'), + ].map((text) => [text, text]), +); + +function noneWithinView(target, radius) { + switch (target) { + case 'flights': + return t('No flights within {radius} of the current view', { radius }); + case 'military': + return t('No military within {radius} of the current view', { radius }); + case 'satellites': + return t('No satellites within {radius} of the current view', { + radius, + }); + case 'vessels': + return t('No vessels within {radius} of the current view', { radius }); + default: + return null; + } +} + +const ERROR_PATTERNS = [ + [ + /^(.+) is enabled on the (.+) feed, but no airborne aircraft is loaded in the (.+) view yet$/, + ([, layer, source, place]) => + t( + '{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet', + { + layer: shownLayerName(layer), + source: sourceLabel(source), + place: localizePlaceName(place), + }, + ), + ], + [ + /^(.+) is enabled, but no airborne aircraft is loaded in the (.+) view yet$/, + ([, layer, place]) => + t( + '{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet', + { layer: shownLayerName(layer), place: localizePlaceName(place) }, + ), + ], + [ + /^(.+) is enabled, but its aircraft feed is unavailable$/, + ([, layer]) => + t('{layer} is enabled, but its aircraft feed is unavailable', { + layer: shownLayerName(layer), + }), + ], + [ + /^(.+) is enabled, but its destination refresh did not complete$/, + ([, layer]) => + t('{layer} is enabled, but its destination refresh did not complete', { + layer: shownLayerName(layer), + }), + ], + [ + /^(.+) is enabled, but (.+) is unavailable$/, + ([, layer, source]) => + t('{layer} is enabled, but {source} is unavailable', { + layer: shownLayerName(layer), + source: sourceLabel(source), + }), + ], + [ + /^Could not arrive at (.+)$/, + ([, place]) => + t('Could not arrive at {place}', { place: localizePlaceName(place) }), + ], + [ + /^(.+) is no longer loaded$/, + ([, name]) => t('{name} is no longer loaded', { name }), + ], + [ + /^Unknown target layer: (.*)$/, + ([, target]) => t('Unknown target layer: {target}', { target }), + ], + [ + /^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/, + ([, target, km]) => noneWithinView(target, shownKm(km)), + ], +]; + +/** A tool error as the card shows it. */ +function shownError(error) { + if (typeof error !== 'string' || !error) return null; + const known = KNOWN_ERRORS.get(error); + if (known) return t(known); + for (const [pattern, show] of ERROR_PATTERNS) { + const match = pattern.exec(error); + const shown = match ? show(match) : null; + if (shown) return shown; + } + return translateKnown(error); +} + +// The card wraps a failed result's lines rather than cutting them: a +// translated error runs longer than the English (voiceCardPresentation). +function errorLines(result) { + return [shownError(result?.error)].filter(Boolean); } function sentence(text) { @@ -189,6 +345,12 @@ function shownPair(items) { } /** Ids such as `austin` or `local-firms` read as names on screen. */ +// A place as the card shows it: a preset city in the interface language, +// whether the call named it ("Tokyo") or gave its id ("tokyo"). +function shownPlace(value, max) { + return localizePlaceName(displayName(value, max)); +} + function displayName(value, max) { const label = spokenLabel(value, max); return /^[a-z0-9-]+$/.test(label) @@ -203,6 +365,12 @@ function layerLabel(result) { return spokenLabel(result?.label || result?.layerId || 'Layer'); } +/** The layer as the card names it, in the active language. */ +function shownLayerLabel(result) { + const name = shownLayerName(result?.label, result?.layerId); + return spokenLabel(name || t('Layer')); +} + /* ---------------- per-tool builders ---------------- */ function setLayerVisibility(result, args = {}) { @@ -212,7 +380,7 @@ function setLayerVisibility(result, args = {}) { const wanted = args.enabled === false ? 'off' : 'on'; return { say: `Couldn't turn ${wanted} ${label}.`, - display: { title: label, lines: [result?.error].filter(Boolean) }, + display: { title: shownLayerLabel(result), lines: errorLines(result) }, }; } const on = result.enabled !== false; @@ -224,30 +392,31 @@ function setLayerVisibility(result, args = {}) { return { say, display: { - title: switchedTitle(label, on), + title: switchedTitle(shownLayerLabel(result), on), chips: on ? feedChips(result.feedState) : [], - sources: result.source ? [{ label: result.source }] : [], + sources: shownSources(result.source), }, }; } function flyToLocation(result) { const label = spokenLabel(result?.label || result?.query || 'there'); + const title = spokenLabel(result?.label || result?.query) || t('there'); if (result?.cancelled) return null; if (!result?.ok) { return { say: sentence(`Couldn't find ${label}`), - display: { title: label, lines: [result?.error].filter(Boolean) }, + display: { title, lines: errorLines(result) }, }; } return { say: sentence(result.arrived ? `Over ${label}` : `Flying to ${label}`), display: { - title: label, + title, lines: result.rangeM ? [ t('Camera range {range}', { - range: kilometers(result.rangeM / 1000), + range: shownKm(result.rangeM / 1000), }), ] : [], @@ -274,7 +443,7 @@ function frameOverhead(result) { say, display: { title: frameTitle(result?.layerId), - lines: [result?.error].filter(Boolean), + lines: errorLines(result), }, }; } @@ -294,7 +463,9 @@ function frameOverhead(result) { display: { title: inFrameTitle(result.layerId, count), lines: [ - t('Within {radius} of the view', { radius }), + t('Within {radius} of the view', { + radius: shownKm(result?.radiusKm), + }), result.detectionEnabled ? t('Labels on') : null, ].filter(Boolean), notes: @@ -309,8 +480,10 @@ function selectNearestAircraft(result) { result?.location?.label || result?.location || 'there', ); const noun = LAYER_NOUNS[result?.layerId] || 'aircraft'; + const shownLocation = + spokenLabel(result?.location?.label || result?.location) || t('there'); const feed = result?.feed || {}; - const sources = feed.source ? [{ label: feed.source }] : []; + const sources = shownSources(feed.source); if (result?.cancelled) return null; if (!result?.ok) { let say; @@ -325,7 +498,7 @@ function selectNearestAircraft(result) { say, display: { title: nearestTitle(result?.layerId), - lines: [result?.error].filter(Boolean), + lines: errorLines(result), chips: feedChips(feed.state), sources, }, @@ -352,7 +525,10 @@ function selectNearestAircraft(result) { title: name, lines: [ distance - ? t('{distance} from {location}', { distance, location }) + ? t('{distance} from {location}', { + distance: shownKm(aircraft.distanceKm), + location: shownLocation, + }) : null, altitude ? t('Altitude {altitude}', { @@ -410,8 +586,12 @@ function getEntityContext(result) { say: null, display: { title, - lines: [selected.layerName].filter(Boolean), - sources: selected.source ? [{ label: selected.source }] : [], + lines: [ + selected.layerName + ? shownLayerName(selected.layerName, selected.layerId) + : null, + ].filter(Boolean), + sources: shownSources(selected.source), }, }; } @@ -443,7 +623,7 @@ function getEntityContext(result) { ? t('Route {route}', { route: routeText(properties, SHOWN_ROUTE) }) : null, ].filter(Boolean), - sources: selected.source ? [{ label: selected.source }] : [], + sources: shownSources(selected.source), }, referents: [ { @@ -491,7 +671,7 @@ function getCurrentViewState(result) { title: t('View state'), lines: enabled.map( (layer) => - `${spokenLabel(layer.name || layer.id, 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`, + `${spokenLabel(shownLayerName(layer.name, layer.id), 32)} · ${layer.count ?? 0} · ${feedStateLabel(layer.feedState || 'nominal')}`, ), }, schedule: 'silent', @@ -627,8 +807,52 @@ function annotationTargets(args) { .filter(Boolean); } +// frame_overhead's targets as the schema names them; English is unchanged. +function frameStepLabel(target) { + switch (target) { + case 'flights': + return t('Frame flights'); + case 'military': + return t('Frame military'); + case 'satellites': + return t('Frame satellites'); + case 'vessels': + return t('Frame vessels'); + default: + return t('Frame {target}', { target }); + } +} + +// The other tools' plan steps, one label per tool in actionSchemas. The tool +// name itself is English model vocabulary and never shows. +const STEP_TITLES = Object.freeze({ + zoom_to_globe: msg('Zoom out to globe'), + show_data_layers_menu: msg('Open data layers'), + set_panel_open: msg('Open or close a panel'), + set_context_mode: msg('Change context mode'), + control_cockpit: msg('Control cockpit'), + set_visual_style: msg('Change visual style'), + set_hud: msg('Adjust HUD'), + set_cyber_sonar: msg('Adjust cyber sonar'), + set_detection: msg('Adjust detection overlay'), + set_map_stack: msg('Change map stack'), + set_post_processing: msg('Adjust post-processing'), + control_scene: msg('Control scene'), + control_cctv: msg('Control CCTV'), + control_radio: msg('Control radio'), + track_entity: msg('Track contact'), + stop_tracking: msg('Stop tracking'), + clear_annotations: msg('Clear marks'), + move_camera: msg('Move camera'), + fly_route: msg('Fly route'), + analyst_query: msg('Analyst query'), + next_iss_pass: msg('Next ISS pass'), + next_satellite_pass: msg('Next satellite pass'), +}); + /** A short on-screen label for one tool call in the voice card's plan. */ export function planStepLabel(name, args = {}) { + if (Object.hasOwn(STEP_TITLES, name)) return t(STEP_TITLES[name]); switch (name) { case 'annotate_map': { const targets = annotationTargets(args); @@ -641,29 +865,40 @@ export function planStepLabel(name, args = {}) { }) : t('Mark {targets}', { targets: shown }); } - case 'fly_to_location': - return t('Fly to {place}', { - place: displayName(args.query || args.locationId || 'location', 40), - }); - case 'select_nearest_aircraft': - return t('Nearest aircraft to {place}', { - place: displayName(args.locationQuery || args.locationId || 'here', 32), - }); - case 'set_layer_visibility': - return switchedTitle( - displayName(args.layerId || 'Layer', 32), - args.enabled !== false, - ); + case 'fly_to_location': { + const place = args.query || args.locationId; + return place + ? t('Fly to {place}', { place: shownPlace(place, 40) }) + : t('Fly to Location'); + } + case 'select_nearest_aircraft': { + const place = args.locationQuery || args.locationId; + return place + ? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) }) + : t('Nearest aircraft to Here'); + } + case 'set_layer_visibility': { + const on = args.enabled !== false; + const label = + layerNameForId(args.layerId) || + (args.layerId ? displayName(args.layerId, 32) : ''); + // "Layer" alone is a noun the languages inflect the state for. + if (!label) return on ? t('Layer on') : t('Layer off'); + return switchedTitle(label, on); + } + case 'adjust_camera_zoom': + return args.direction === 'out' ? t('Zoom out') : t('Zoom in'); case 'get_entity_context': return t('Read the view'); case 'get_current_view_state': return t('Check view state'); case 'frame_overhead': - return t('Frame {target}', { - target: spokenLabel(args.target || 'flights', 24), - }); + return frameStepLabel(spokenLabel(args.target || 'flights', 24)); default: - return spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40); + // A tool the card has no label for: English shows its name as before. + return getLocale() === 'en' + ? spokenLabel(String(name || 'Action').replace(/_/g, ' '), 40) + : t('Action'); } } diff --git a/src/voice/voiceCardErrorLines.i18n.test.mjs b/src/voice/voiceCardErrorLines.i18n.test.mjs new file mode 100644 index 000000000..e1697448a --- /dev/null +++ b/src/voice/voiceCardErrorLines.i18n.test.mjs @@ -0,0 +1,109 @@ +// K-24: on a failed tool step the voice card shows the English `result.error` +// from gevActions under a translated title and step status. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../i18n/index.js'; +import { attachVoiceResult, spokenLabel } from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; + +// Error texts as gevActions.js builds them (lines 2442, 646–647, 2476). +const CASES = [ + { + name: 'frame_overhead', + args: { target: 'military' }, + result: { + ok: false, + action: 'frame_overhead', + layerId: 'military', + error: 'The military layer is not enabled', + }, + }, + { + name: 'select_nearest_aircraft', + args: { locationQuery: 'Madrid' }, + result: { + ok: false, + action: 'select_nearest_aircraft', + stage: 'nearest', + cancelled: false, + error: + 'Live Flights is enabled, but no airborne aircraft is loaded in the Madrid view yet', + location: { label: 'Madrid' }, + layer: { label: 'Live Flights' }, + feed: { state: 'nominal', source: 'OpenSky' }, + count: 0, + }, + }, + { + name: 'frame_overhead', + args: { target: 'flights' }, + result: { + ok: false, + action: 'frame_overhead', + layerId: 'flights', + radiusKm: 150, + count: 0, + error: 'No flights within 150 km of the current view', + }, + }, +]; + +function cardFor(locale, { name, args, result }) { + setLocale(locale); + let state = reduceVoiceCard(initialVoiceCardState(), { + type: 'action-call', + id: 'c1', + name, + arguments: args, + }); + state = reduceVoiceCard(state, { + type: 'action-result', + id: 'c1', + name, + result: attachVoiceResult(name, result, args), + }); + return voiceCardView(state); +} + +for (const locale of ['es', 'ru']) { + for (const c of CASES) { + test(`${locale}: ${c.name} failure shows the card lines in the UI language`, () => { + const view = cardFor(locale, c); + try { + assert.ok(view.result.visible, 'card shows a result'); + assert.notEqual(view.result.title, '', 'card has a title'); + // Title and step status are translated (this part passes today). + assert.notEqual( + view.steps[0].statusText, + 'failed', + 'step status translated', + ); + // The failure line is the raw English tool error. + for (const line of view.result.lines) { + assert.ok( + !/\b(is not enabled|no airborne aircraft|of the current view)\b/.test( + line, + ), + `English tool error shown under ${locale} title "${view.result.title}" / step "${view.steps[0].label}: ${view.steps[0].statusText}": "${line}"`, + ); + } + } finally { + setLocale('en'); + } + }); + } +} + +test('en: the card shows the tool error as the model got it', () => { + for (const c of CASES) { + const view = cardFor('en', c); + // Whole, not cut at 80 characters: the card wraps an error line. + assert.deepEqual(view.result.lines, [ + spokenLabel(c.result.error, Infinity), + ]); + } +}); diff --git a/src/voice/voiceCardLocale.test.mjs b/src/voice/voiceCardLocale.test.mjs new file mode 100644 index 000000000..83c81974d --- /dev/null +++ b/src/voice/voiceCardLocale.test.mjs @@ -0,0 +1,88 @@ +// K-7: the voice card names layers, sources and distances in the interface +// language; what the model receives stays English. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; +import { attachVoiceResult, planStepLabel } from './speech.js'; +import { voiceCardView } from './voiceCardPresentation.js'; + +for (const locale of ['ru', 'es']) { + test(`voice card is in ${locale} for a layer switch`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult( + 'set_layer_visibility', + { + ok: true, + layerId: 'flights', + label: 'Live Flights', + enabled: true, + source: 'Flights', + }, + { layerId: 'flights', enabled: true }, + ); + const view = voiceCardView({ steps: [], result: out }); + assert.doesNotMatch(out.display.title, /Live Flights/); + assert.doesNotMatch(JSON.stringify(view), /Flights/); + }); + test(`voice card distance is in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult('fly_to_location', { + ok: true, + label: 'Paris', + rangeM: 2500, + }); + assert.doesNotMatch(out.display.lines[0], /2\.5 km/); + }); + test(`plan label is in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const a = planStepLabel('select_nearest_aircraft', {}); + const b = planStepLabel('set_layer_visibility', { + layerId: 'earthquakes', + enabled: true, + }); + assert.doesNotMatch(a, /Here/); + assert.doesNotMatch(b, /Earthquakes/); + }); +} + +test('en: layer switch, distance and plan labels read as before', () => { + const out = attachVoiceResult( + 'set_layer_visibility', + { + ok: true, + layerId: 'flights', + label: 'Live Flights', + enabled: true, + source: 'Flights', + }, + { layerId: 'flights', enabled: true }, + ); + assert.equal(out.say, 'Live Flights on.'); + assert.equal(out.display.title, 'Live Flights on'); + assert.deepEqual(out.display.sources, [{ label: 'Flights' }]); + const fly = attachVoiceResult('fly_to_location', { + ok: true, + label: 'Paris', + rangeM: 2500, + }); + assert.equal(fly.display.lines[0].replace(/\s/g, ' '), 'Camera range 2.5 km'); + assert.equal( + planStepLabel('select_nearest_aircraft', {}), + 'Nearest aircraft to Here', + ); + assert.equal( + planStepLabel('frame_overhead', { target: 'vessels' }), + 'Frame vessels', + ); + // The plan names the layer as its result card does, not by its id. + assert.equal( + planStepLabel('set_layer_visibility', { + layerId: 'earthquakes', + enabled: true, + }), + 'Earthquakes (24h) on', + ); +}); diff --git a/src/voice/voiceCardPlanSteps.i18n.test.mjs b/src/voice/voiceCardPlanSteps.i18n.test.mjs new file mode 100644 index 000000000..22db9e28e --- /dev/null +++ b/src/voice/voiceCardPlanSteps.i18n.test.mjs @@ -0,0 +1,246 @@ +// PDF review C3, B4, E5, E6 and proposal 1: the voice card's plan steps name +// every tool in the interface language, the "Layer" step agrees in gender, +// error lines and long headlines show whole, and the Contacts snapshot time +// reads as a clock, not ISO. What the model receives stays English. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setLocale } from '../i18n/index.js'; +import { GEV_ACTION_SCHEMAS } from './actionSchemas.js'; +import { presentResult, shownSnapshotTime } from './resultDisplay.js'; +import { attachVoiceResult, planStepLabel } from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; + +const TOOLS = GEV_ACTION_SCHEMAS.map((schema) => schema.name); + +function inLocale(locale, run) { + setLocale(locale); + try { + return run(); + } finally { + setLocale('en'); + } +} + +test('C3: every tool has a plan label, never its raw name', () => { + for (const name of TOOLS) { + const label = planStepLabel(name, {}); + assert.notEqual(label, name.replace(/_/g, ' '), name); + assert.doesNotMatch(label, /_/, name); + } + assert.equal(planStepLabel('analyst_query', {}), 'Analyst query'); + assert.equal( + planStepLabel('adjust_camera_zoom', { direction: 'in' }), + 'Zoom in', + ); + assert.equal( + planStepLabel('adjust_camera_zoom', { direction: 'out' }), + 'Zoom out', + ); + // A tool the card does not know keeps its name in English. + assert.equal(planStepLabel('brand_new_tool', {}), 'brand new tool'); +}); + +for (const locale of ['ru', 'es']) { + test(`C3: ${locale} plan labels are all translated`, () => { + const english = Object.fromEntries( + TOOLS.map((name) => [name, planStepLabel(name, { direction: 'out' })]), + ); + inLocale(locale, () => { + for (const name of TOOLS) { + // A layer switch without a layer is checked below (B4). + const label = planStepLabel(name, { direction: 'out' }); + assert.notEqual(label, english[name], `${name}: "${label}"`); + } + }); + }); +} + +test('C3/E10: analyst query and an unknown tool in ru and es', () => { + inLocale('ru', () => { + assert.equal(planStepLabel('analyst_query', {}), 'Аналитический запрос'); + assert.equal(planStepLabel('brand_new_tool', {}), 'Действие'); + }); + inLocale('es', () => { + assert.equal(planStepLabel('analyst_query', {}), 'Consulta analítica'); + assert.equal(planStepLabel('brand_new_tool', {}), 'Acción'); + }); +}); + +test('B4: a layer step with no layer agrees with "Capa"', () => { + const step = (enabled) => planStepLabel('set_layer_visibility', { enabled }); + assert.equal(step(true), 'Layer on'); + assert.equal(step(false), 'Layer off'); + inLocale('es', () => { + assert.equal(step(true), 'Capa: activada'); + assert.equal(step(false), 'Capa: desactivada'); + // A named layer keeps the shared "{label} on" wording. + assert.match( + planStepLabel('set_layer_visibility', { + layerId: 'earthquakes', + enabled: true, + }), + /: activado$/, + ); + }); + inLocale('ru', () => { + assert.equal(step(true), 'Слой: вкл.'); + assert.equal(step(false), 'Слой: выкл.'); + }); +}); + +function cardAfter(name, result, args = {}) { + let state = reduceVoiceCard(initialVoiceCardState(), { + type: 'action-call', + id: 'c1', + name, + arguments: args, + }); + state = reduceVoiceCard(state, { + type: 'action-result', + id: 'c1', + name, + result: attachVoiceResult(name, result, args), + }); + return voiceCardView(state); +} + +const NO_PLACE = { + ok: false, + action: 'select_nearest_aircraft', + stage: 'location', + error: + 'Nearest-aircraft selection needs a preset, place name, or latitude and longitude', + feed: {}, +}; + +test('proposal 1: an error line shows whole, in every language', () => { + for (const [locale, expected] of [ + ['en', NO_PLACE.error], + [ + 'ru', + 'Для выбора ближайшего самолёта нужен пресет, название места или широта и долгота', + ], + [ + 'es', + 'Para seleccionar la aeronave más cercana se necesita un preajuste, un nombre de lugar o la latitud y la longitud', + ], + ]) { + const view = inLocale(locale, () => + cardAfter('select_nearest_aircraft', NO_PLACE), + ); + assert.deepEqual(view.result.lines, [expected], locale); + } +}); + +test('proposal 1: a successful line keeps its 80-character cap', () => { + const long = 'x'.repeat(120); + const view = voiceCardView({ + ...initialVoiceCardState(), + result: { display: { title: 'T', lines: [long] } }, + }); + assert.equal(view.result.lines[0].length, 80); +}); + +test('E5: a translated analyst headline fits the card title whole', () => { + const title = + 'Al menos 12 aeronaves militares en un radio de 40 km de Los Angeles'; + const card = () => + voiceCardView({ + ...initialVoiceCardState(), + result: { display: { title, lines: [] } }, + }).result.title; + assert.equal(inLocale('es', card), title); + // English keeps its 64-character title, equal to the spoken headline. + assert.equal(card().length, 64); +}); + +test('E6: the snapshot time reads as a UTC clock, dated when not today', () => { + const iso = '2026-10-15T14:32:05.000Z'; + const sameDay = Date.parse('2026-10-15T23:00:00Z'); + const nextDay = Date.parse('2026-10-16T00:10:00Z'); + assert.equal(shownSnapshotTime(iso, sameDay), iso, 'English as before'); + inLocale('ru', () => { + assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC'); + assert.equal(shownSnapshotTime(iso, nextDay), '15.10.2026, 14:32:05 UTC'); + }); + inLocale('es', () => { + assert.equal(shownSnapshotTime(iso, sameDay), '14:32:05 UTC'); + assert.equal(shownSnapshotTime(iso, nextDay), '15/10/2026, 14:32:05 UTC'); + assert.equal(shownSnapshotTime('not a time'), 'not a time'); + }); +}); + +test('E6: the Contacts note shows the clock; the model note stays ISO', () => { + const evaluatedAt = new Date(); + evaluatedAt.setUTCHours(14, 32, 5, 0); + const iso = evaluatedAt.toISOString(); + const note = `Contacts panel snapshot at ${iso} — the same retained cohort supplies the card, speech, and follow-up memory.`; + const result = { + ok: true, + count: 12, + complete: true, + scopeLabel: 'in view', + display: { scope: 'in view' }, + coverage: { note }, + items: [], + }; + const notes = inLocale('es', () => + presentResult('analyst_query', result).display.notes.join(' | '), + ); + assert.match(notes, /\(14:32:05 UTC\)/); + assert.doesNotMatch(notes, /T14:32/); + assert.equal(result.coverage.note, note, 'the result is not changed'); +}); + +// C12: preset places show in the interface language on the card; the model's +// error and scope stay English. +test('C12: plan steps and errors name a preset place in the UI language', () => { + const failed = { + ok: false, + action: 'select_nearest_aircraft', + stage: 'nearest', + error: + 'Live Flights is enabled, but no airborne aircraft is loaded in the Tokyo view yet', + location: { label: 'Tokyo' }, + feed: { state: 'nominal' }, + }; + assert.equal( + planStepLabel('fly_to_location', { query: 'Tokyo' }), + 'Fly to Tokyo', + ); + inLocale('es', () => { + assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Tokio/); + assert.match( + planStepLabel('select_nearest_aircraft', { locationId: 'tokyo' }), + /Tokio/, + ); + const view = cardAfter('select_nearest_aircraft', failed); + assert.match(view.result.lines[0], /Tokio/); + }); + inLocale('ru', () => { + assert.match(planStepLabel('fly_to_location', { query: 'Tokyo' }), /Токио/); + }); + assert.match(failed.error, /Tokyo view/, 'the result stays English'); +}); + +test('C12: the analyst scope names Los Angeles in the UI language', () => { + const result = { + ok: true, + count: 12, + complete: false, + scopeLabel: 'within 40 km of Los Angeles', + display: { scope: 'within 40 km of Los Angeles' }, + coverage: { layersQueried: [{ layerKey: 'military' }] }, + items: [], + }; + const title = inLocale( + 'es', + () => presentResult('analyst_query', result).display.title, + ); + assert.match(title, /Los Ángeles$/, 'the headline is not cut at 64'); + assert.equal(result.scopeLabel, 'within 40 km of Los Angeles'); +}); diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js index 0f0146ce1..e2fde1bb2 100644 --- a/src/voice/voiceCardPresentation.js +++ b/src/voice/voiceCardPresentation.js @@ -2,7 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js'; import { presentResult } from './resultDisplay.js'; import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js'; -import { msg, t } from '../i18n/index.js'; +import { getLocale, msg, t } from '../i18n/index.js'; /** * Pure presentation of the voice card nested in the voice control: captions, * the current plan, the latest result and its numbered referents. @@ -17,6 +17,16 @@ const MAX_STEPS = 6; const MAX_LINES = 6; const MAX_NOTES = 6; const MAX_CAPTION = 220; +// Titles and lines wrap in the card; the caps only stop a runaway value. A +// translated headline runs longer than the English one ("Al menos 12 aeronaves +// militares en un radio de 40 km de Los Ángeles"), so other languages cut it at +// 120; English keeps 64. A failed result's lines (its error) are never cut. +const MAX_TITLE = 64; +const MAX_TRANSLATED_TITLE = 120; +const MAX_LINE = 80; + +const titleMax = () => + getLocale() === 'en' ? MAX_TITLE : MAX_TRANSLATED_TITLE; const STATUS_TEXT = Object.freeze({ running: msg('in progress'), @@ -205,12 +215,14 @@ export function reduceVoiceCard(state, event) { ...next, result: { display: shown.display, + // A failed result's lines are its error: shown whole, wrapped. + wholeLines: result.ok === false, referents: normalizeReferents( shown.referents, MAX_DISPLAYED_REFERENTS, ), }, - announce: spokenLabel(shown.display.title, 64), + announce: spokenLabel(shown.display.title, titleMax()), }); } case 'outline': { @@ -293,10 +305,12 @@ export function voiceCardView(state) { })), result: { visible: Boolean(display?.title), - title: spokenLabel(display?.title || '', 64), + title: spokenLabel(display?.title || '', titleMax()), chips: (display?.chips || []).map((chip) => spokenLabel(chip.label, 24)), lines: (display?.lines || []) - .map((line) => spokenLabel(line, 80)) + .map((line) => + spokenLabel(line, state.result?.wholeLines ? Infinity : MAX_LINE), + ) .slice(0, MAX_LINES), referents: (state.result?.referents || []) .slice(0, MAX_DISPLAYED_REFERENTS)