From b20c80e590032f12484079bf6f73200a429605be Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 16:47:39 -0800 Subject: [PATCH] Switch language in place, without a reload Choosing a language in the picker now applies it at once. The choice is stored and ?lang is kept in step through replaceState, and the picker keeps focus. Code that writes translated text itself re-presents it on a switch: onLocaleChange() lets an owner listen, removed at teardown, and status writers hand over a function of the locale (voice cards, scene director, SDR, draw hints, imagery split, map sources, Bhote Koshi media) so the words are rebuilt rather than re-sent. The HUD, cockpit, radio, CCTV, launch, awareness and credit texts follow the same hook, and applyDocumentTranslations can be applied again without undoing text code wrote since. The unsaved-work registry and persistLocaleAndReload existed only to guard the reload, so they go, with their catalog entries. --- changelog.d/0000-vue-and-pinia.md | 8 + docs/CURRENT-STATE.md | 8 +- docs/I18N.md | 12 +- docs/TRANSLATORS.md | 2 +- src/annotations/drawTool.js | 45 +++--- src/annotations/drawToolBehaviour.test.mjs | 24 --- src/app/scene.js | 3 +- src/creditKeyboard.js | 16 +- src/creditKeyboard.test.mjs | 13 ++ src/data/bhoteKoshiEmbeddedMedia.js | 125 +++++++++++----- src/data/bhoteKoshiEvent.js | 29 +++- src/hud.js | 24 ++- src/i18n/i18n.test.mjs | 77 ++++++---- src/i18n/index.js | 130 ++++++++-------- src/i18n/locales/de.json | 7 - src/i18n/locales/es.json | 7 - src/i18n/locales/fr.json | 7 - src/i18n/locales/it.json | 7 - src/i18n/locales/ja.json | 7 - src/i18n/locales/pt.json | 7 - src/i18n/locales/ru.json | 7 - src/i18n/locales/uk.json | 7 - src/i18n/locales/zh-CN.json | 7 - src/i18n/locales/zh-Hant.json | 7 - src/keySetup.js | 15 +- src/keySetup.test.mjs | 29 ---- src/layers/awareness/panel.js | 15 +- src/layers/launches/panel.js | 10 +- src/layers/radio/presentation.js | 20 +++ src/layers/radio/rendering.js | 6 +- src/layers/radio/state.js | 1 + src/maps/controller.js | 4 +- src/maps/defaultSources.js | 11 +- src/scenes/director.js | 110 +++++++++----- src/scenes/director.test.mjs | 2 +- src/scenes/playbackAdapter.js | 4 +- src/scenes/sceneTitleDisplay.test.mjs | 4 +- src/scenes/sharing.js | 4 +- src/sdr/controller.js | 132 +++++++++++++---- src/sdr/sdrLocaleSwitch.test.mjs | 48 ++++++ src/sharelink.lang.test.mjs | 8 +- src/standalone/application.js | 16 +- src/ui/applicationShell.js | 68 ++++----- src/ui/cctvControls.js | 10 ++ src/ui/cctvPresentation.js | 25 +++- src/ui/cockpitBriefing.js | 44 +++++- src/ui/cockpitController.js | 38 ++++- src/ui/cockpitSignals.js | 9 ++ src/ui/geoFileImport.js | 15 +- src/ui/geoFileImport.test.mjs | 14 -- src/ui/i18n.js | 15 +- src/ui/imagerySplit.js | 16 +- src/ui/kit/UiPanelToggle.vue | 6 +- src/ui/layerPanel.js | 8 + src/ui/localSdrControls.js | 4 +- src/ui/localePicker.js | 14 +- src/ui/localePicker.test.mjs | 20 +-- src/ui/localeSwitch.test.mjs | 130 ++++++++++++++++ src/ui/localeSwitchReload.test.mjs | 164 --------------------- src/ui/locationControls.js | 12 +- src/ui/mapSourceControls.js | 6 +- src/ui/radioBindings.js | 2 + src/ui/radioControls.js | 11 ++ src/ui/radioPresentation.js | 5 +- src/ui/recentImagery.js | 12 +- src/ui/sceneControls.js | 10 +- src/ui/shellFeedback.js | 23 ++- src/ui/stores/locale.js | 4 +- src/ui/streetLevelControls.js | 7 +- src/ui/uiLifetime.js | 9 ++ src/ui/views/ProviderSettingsView.vue | 2 +- src/ui/views/panels/DisplayPanel.vue | 12 +- src/ui/views/panels/LocalePicker.vue | 16 +- src/ui/views/panels/RadioPanel.vue | 4 +- src/ui/views/panels/StreetLevelPanel.vue | 8 +- src/ui/visualSettings.js | 11 +- src/voice/control.js | 77 ++++++++-- src/voice/panel/host.js | 19 ++- src/voice/panel/manualPanel.js | 3 +- src/voice/panel/preferences.js | 7 +- src/voice/providerCommands.test.mjs | 6 - src/voice/realtimeController.js | 19 ++- src/voice/realtimeInput.js | 8 +- src/voice/realtimeSession.js | 12 +- src/voice/session.test.mjs | 6 - src/voice/sessionCommands.js | 30 ++-- src/voice/voiceCard.js | 5 + 87 files changed, 1194 insertions(+), 777 deletions(-) create mode 100644 src/sdr/sdrLocaleSwitch.test.mjs create mode 100644 src/ui/localeSwitch.test.mjs delete mode 100644 src/ui/localeSwitchReload.test.mjs diff --git a/changelog.d/0000-vue-and-pinia.md b/changelog.d/0000-vue-and-pinia.md index 2de4a80e3..2716cfc38 100644 --- a/changelog.d/0000-vue-and-pinia.md +++ b/changelog.d/0000-vue-and-pinia.md @@ -1,3 +1,11 @@ +- The language picker switches language in place instead of reloading the + page. Views re-render; code that publishes translated text words it again + through `onLocaleChange` (`src/i18n/index.js`), and ``/`dir` and + the page's `data-i18n` markup follow. `persistLocale` replaces + `persistLocaleAndReload`, and `registerUnsavedWork` and the switch's + unsaved-work prompt are removed, since a switch no longer loses anything + (samehkhamis, #0000). + - The application views translate their text with `t()` as they render, so it follows the active locale; they no longer carry `data-i18n*` attributes, and `npm run i18n` rejects those in a Vue template. The boot diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f56875125..85d5f52a2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3854,10 +3854,10 @@ Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, * select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops - `?lang=` (keeps it only when storage is blocked) and reloads. It asks first - when the reload would lose work registered with `registerUnsavedWork`: - unsaved keys or CCTV calibration, Draw marks, imported map files, the open - cockpit, a voice session. + `?lang=` (keeps it only when storage is blocked) and switches in place: + views re-render, code that published translated text words it again through + `onLocaleChange`, and ``/`dir` and the page's `data-i18n` markup + follow. Nothing is lost; a toast already on screen keeps its words. - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) extracts every message and fails on missing, empty, unused, placeholder-mismatched, still-English (plural forms included) or diff --git a/docs/I18N.md b/docs/I18N.md index 3e2fb61d1..0d57f14d5 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -47,7 +47,17 @@ Static page markup that no view renders (the loading screen in `index.html`, a h ``` -`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. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Attribute markup is translated once at boot. Switching language still reloads the page: views would follow a new locale, but much of their text is published already translated by code that does not. 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 picker asks before reloading. +`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. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated at boot and again after every language switch. + +## Switching language + +The language picker switches in place, without a reload. A view that calls `t()` while it renders follows by itself. Code that translates text and hands it on (to a store, a published view object, a layer's state, a DOM node) must word it again when the language changes: + +- **Present again.** `onLocaleChange(listener)` from `src/i18n/index.js` calls the listener after each change and returns the function that removes it; call that at teardown. The listener presents the view again from what it keeps, as `layerPanel.js` and `cockpitController.js` do. +- **Keep the English, translate when presenting.** Store `msg()` text or a function that words it, not its translation: the voice control takes `update(part, { text: () => t('MIC') })`, the scene director's status and the SDR receiver's messages keep such functions, and `imagerySplit.js` takes its titles that way. +- **Translated constants in a view** go in `computed`, not in a plain `const` set up once. + +A toast already on screen keeps its words. ## Rules diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index f862d8e4c..973f3f618 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 picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page. +The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching applies at once, without a reload. ## The check diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 4ba731522..3e6cd9db3 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -49,7 +49,7 @@ import { removeLastVertex, } from './drawMode.js'; -import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; +import { onLocaleChange, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; @@ -107,17 +107,6 @@ export function initDrawTool({ 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 @@ -129,10 +118,17 @@ export function initDrawTool({ ); const present = () => publish({ active, shape, color, label, hint }); + // The hint is kept as the call that words it, so a language switch can + // word it again. + let hintText = () => ''; const setHint = (text) => { - hint = text; + hintText = text; + hint = text(); present(); }; + const offLocale = onLocaleChange(() => { + if (!destroyed) setHint(hintText); + }); // ---- preview --------------------------------------------------------- const vertexPositions = () => @@ -167,7 +163,7 @@ export function initDrawTool({ previewEntities.length = 0; if (!session) { previewLine.show = false; - setHint(drawHint(null)); + setHint(() => drawHint(null)); viewer.scene.requestRender(); return; } @@ -193,7 +189,7 @@ export function initDrawTool({ }), ); } - setHint(drawHint(session)); + setHint(() => drawHint(session)); viewer.scene.requestRender(); }; @@ -216,14 +212,14 @@ export function initDrawTool({ // A click that changed nothing still deserves an answer when the reason is // a limit rather than the harmless tail of a double-click. if (reason === 'full') - setHint( + setHint(() => t( 'That shape already has {count} points — finish it or press Backspace.', { count: MAX_VERTICES }, ), ); else if (reason === 'invalid') - setHint(t('That point is off the globe — click on the world.')); + setHint(() => t('That point is off the globe — click on the world.')); }; const onMove = (event) => { if (!session || session.shape === 'pin' || destroyed) return; @@ -241,7 +237,7 @@ export function initDrawTool({ if (reason !== 'ok') { // 'too-few' is just "keep clicking" and the hint already says so; // a degenerate or off-globe shape needs to be told why it was refused. - if (reason !== 'too-few') setHint(drawHint(session)); + if (reason !== 'too-few') setHint(() => drawHint(session)); return null; } const spec = finishSpec(session, { label, color }); @@ -260,11 +256,12 @@ export function initDrawTool({ flyTo: false, }); if (destroyed || attempt !== generation) return result; - if (result?.drawn === 0) setHint(t('That shape could not be placed.')); + if (result?.drawn === 0) + setHint(() => t('That shape could not be placed.')); return result; } catch (error) { if (destroyed || attempt !== generation) return null; - setHint( + setHint(() => t('Could not place the shape: {error}', { error: error?.message || error, }), @@ -286,7 +283,7 @@ export function initDrawTool({ if (session) session = createDrawSession(shape); annotations.clear(); syncPreview(); - if (!destroyed && !active) setHint(t('Board cleared.')); + if (!destroyed && !active) setHint(() => t('Board cleared.')); }; // ---- keys: only while drawing, never while typing in another field ---- @@ -354,7 +351,7 @@ export function initDrawTool({ // Somebody else is using the pointer — possibly an older instance of // this same tool that has not finished tearing down. Say so instead of // half-starting. - setHint( + setHint(() => t('{owner} is using the pointer — close it first.', { owner: translateKnown(pointerOwnerName(pointerOwner())), }), @@ -459,7 +456,7 @@ export function initDrawTool({ }, clearDraw: clearAll, }); - setHint(drawHint(null)); + setHint(() => drawHint(null)); const api = { get active() { @@ -521,7 +518,7 @@ export function initDrawTool({ generation += 1; if (active) setActive(false); destroyed = true; - unregisterUnsavedWork(); + offLocale(); disconnect(); releaseSceneHandler(); releasePointer(lease); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index db02febbc..0954600a4 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -6,7 +6,6 @@ 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, @@ -238,29 +237,6 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; /* ── the tests ─────────────────────────────────────────────────────────── */ -// First in the file: tools left by later tests 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.act('toggleDraw'); - 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 { diff --git a/src/app/scene.js b/src/app/scene.js index 62434613f..1455b8268 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,7 +14,7 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; -import { t } from '../i18n/index.js'; +import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ @@ -60,6 +60,7 @@ export async function createApplicationScene({ defer(installTrackpadPinchZoom(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); + defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); loaderStatus.textContent = googleApiKey || googleTokens || cesiumToken ? t('Loading Google 3D Tiles...') diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js index 5d4e8ce7d..9b6b65c8f 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 { getLocale, t } from './i18n/index.js'; +import { t } from './i18n/index.js'; export function configureCreditKeyboardAccess(root = document) { const expand = root?.querySelector?.( @@ -14,19 +14,19 @@ export function configureCreditKeyboardAccess(root = document) { 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:'); - } + // display, and never rewrites them; English is Cesium's own text. Called + // again after a locale change, this words them again. + 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'); expand.setAttribute('aria-haspopup', 'dialog'); expand.setAttribute('aria-controls', lightbox.id); - expand.setAttribute('aria-expanded', 'false'); + if (!expand.hasAttribute?.('aria-expanded')) + expand.setAttribute('aria-expanded', 'false'); close.setAttribute('role', 'button'); close.setAttribute('tabindex', '0'); close.setAttribute('aria-label', t('Close data attribution')); diff --git a/src/creditKeyboard.test.mjs b/src/creditKeyboard.test.mjs index 6a0780ada..a4e4ea787 100644 --- a/src/creditKeyboard.test.mjs +++ b/src/creditKeyboard.test.mjs @@ -221,3 +221,16 @@ for (const [locale, link, title] of [ setLocale('en'); } }); + +test('setting up again after a switch back to English restores the English texts', () => { + const f = textFixture(); + setLocale('ru'); + try { + configureCreditKeyboardAccess(f.root); + } finally { + setLocale('en'); + } + configureCreditKeyboardAccess(f.root); + assert.equal(f.expand.textContent, 'Data attribution'); + assert.equal(f.title.textContent, 'Data provided by:'); +}); diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js index 53e21f8f4..4d6ee5b08 100644 --- a/src/data/bhoteKoshiEmbeddedMedia.js +++ b/src/data/bhoteKoshiEmbeddedMedia.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, onLocaleChange } from '../i18n/index.js'; const YOUTUBE_HOSTS = new Set([ 'youtube.com', @@ -674,6 +674,30 @@ export function createBhoteKoshiEmbeddedMedia({ let record = null; let warmRecord = null; let generation = 0; + // Text the cards show, kept as the calls that word it so a locale change + // words it again. A node is forgotten once it has left the page. + const worded = new Map(); + function forgetDetached() { + for (const node of worded.keys()) + if (!node.isConnected) worded.delete(node); + } + function word(node, property, text) { + let properties = worded.get(node); + if (!properties) worded.set(node, (properties = new Map())); + properties.set(property, text); + writeWord(node, property, text()); + } + function writeWord(node, property, value) { + if (property === 'textContent' || property === 'title') + node[property] = value; + else node.setAttribute(property, value); + } + const offLocale = onLocaleChange(() => { + forgetDetached(); + for (const [node, properties] of worded) + for (const [property, text] of properties) + writeWord(node, property, text()); + }); const leavingRecords = new Set(); const prefersReducedMotion = () => @@ -818,6 +842,7 @@ export function createBhoteKoshiEmbeddedMedia({ current.youtubeSession?.destroy(); current.card?.remove?.(); leavingRecords.delete(current); + forgetDetached(); } const position = () => { @@ -871,6 +896,7 @@ export function createBhoteKoshiEmbeddedMedia({ destroyFacebookSession(warmRecord.facebookSession); else warmRecord.iframe?.remove?.(); warmRecord = null; + forgetDetached(); } function takeWarmFacebook(key, host) { @@ -917,9 +943,11 @@ export function createBhoteKoshiEmbeddedMedia({ startAtSec, endAtSec, }); - iframe.title = t('Preloading {provider} media', { - provider: providerLabel(source.provider), - }); + word(iframe, 'title', () => + t('Preloading {provider} media', { + provider: providerLabel(source.provider), + }), + ); iframe.tabIndex = -1; iframe.setAttribute('aria-hidden', 'true'); iframe.loading = 'eager'; @@ -930,7 +958,7 @@ export function createBhoteKoshiEmbeddedMedia({ } async function mountX(current, token) { - current.status.textContent = t('LOADING X POST…'); + word(current.status, 'textContent', () => t('LOADING X POST…')); try { const api = await xLoader(documentRef, globalRef); if (token !== generation || record !== current) return; @@ -950,14 +978,18 @@ export function createBhoteKoshiEmbeddedMedia({ throw new Error( msg('The X post is unavailable or cannot be embedded.'), ); - current.status.textContent = t('X POST · PROVIDER CONTROLS'); + word(current.status, 'textContent', () => + t('X POST · PROVIDER CONTROLS'), + ); position(); } catch (error) { if (token !== generation || record !== current) return; current.card.classList.add('is-unavailable'); - current.status.textContent = t('{reason} OPEN ORIGINAL.', { - reason: t(String(error?.message || msg('X embed unavailable'))), - }); + word(current.status, 'textContent', () => + t('{reason} OPEN ORIGINAL.', { + reason: t(String(error?.message || msg('X embed unavailable'))), + }), + ); position(); } } @@ -970,22 +1002,28 @@ export function createBhoteKoshiEmbeddedMedia({ session.shouldPlay = autoplay === true; session.onReady = () => { if (token !== generation || record !== current) return; - current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS'); + word(current.status, 'textContent', () => + t('FACEBOOK · PROVIDER CONTROLS'), + ); current.card.classList.remove('is-unavailable'); position(); }; session.onError = (error) => { if (token !== generation || record !== current) return; current.card.classList.add('is-unavailable'); - current.status.textContent = t('{reason} OPEN ORIGINAL.', { - reason: t(String(error?.message || msg('Facebook embed unavailable'))), - }); + word(current.status, 'textContent', () => + t('{reason} OPEN ORIGINAL.', { + reason: t( + String(error?.message || msg('Facebook embed unavailable')), + ), + }), + ); position(); }; session.onPlaybackBlocked = () => { if (token !== generation || record !== current) return; - current.status.textContent = t( - 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS', + word(current.status, 'textContent', () => + t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'), ); }; if (session.ready) session.onReady(); @@ -1036,8 +1074,7 @@ export function createBhoteKoshiEmbeddedMedia({ const portraitClass = isPortraitMedia(media) ? ' is-portrait' : ''; card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`; card.dataset.provider = source.provider; - card.setAttribute( - 'aria-label', + word(card, 'aria-label', () => t('Embedded {provider} media for {source}', { provider: providerLabel(source.provider), source: observation?.title @@ -1057,19 +1094,23 @@ export function createBhoteKoshiEmbeddedMedia({ player.className = 'bhote-embedded-callout-player ui-scroll'; const footer = documentRef.createElement('footer'); const title = documentRef.createElement('strong'); - title.textContent = bhoteEventText( - observation?.shortTitle || observation?.title || t('Witness source'), - ).toUpperCase(); + word(title, 'textContent', () => + bhoteEventText( + observation?.shortTitle || observation?.title || t('Witness source'), + ).toUpperCase(), + ); const status = documentRef.createElement('small'); status.setAttribute('role', 'status'); - status.textContent = t('{provider} · EMBEDDED SOURCE', { - provider: providerLabel(source.provider), - }); + word(status, 'textContent', () => + t('{provider} · EMBEDDED SOURCE', { + provider: providerLabel(source.provider), + }), + ); const link = documentRef.createElement('a'); link.href = source.url; link.target = '_blank'; link.rel = 'noopener noreferrer'; - link.textContent = t('OPEN ORIGINAL ↗'); + word(link, 'textContent', () => t('OPEN ORIGINAL ↗')); footer.append(title, status, link); const body = documentRef.createElement('div'); body.className = 'bhote-embedded-callout-body'; @@ -1109,12 +1150,14 @@ export function createBhoteKoshiEmbeddedMedia({ url.searchParams.set('origin', globalRef.location.origin); iframe.src = url.href; } - iframe.title = t('{provider} media: {source}', { - provider: providerLabel(source.provider), - source: observation?.title - ? bhoteEventText(observation.title) - : t('witness source'), - }); + word(iframe, 'title', () => + t('{provider} media: {source}', { + provider: providerLabel(source.provider), + source: observation?.title + ? bhoteEventText(observation.title) + : t('witness source'), + }), + ); iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen'; iframe.allowFullscreen = true; @@ -1131,9 +1174,11 @@ export function createBhoteKoshiEmbeddedMedia({ record.youtubeSession.getState().phase, ) ) { - status.textContent = t('{provider} · PROVIDER CONTROLS', { - provider: providerLabel(source.provider), - }); + word(status, 'textContent', () => + t('{provider} · PROVIDER CONTROLS', { + provider: providerLabel(source.provider), + }), + ); } position(); }, @@ -1151,11 +1196,13 @@ export function createBhoteKoshiEmbeddedMedia({ isCurrent: () => token === generation && record === current, onStatus: (phase) => { if (phase !== 'completed') - status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', { - state: YOUTUBE_PHASE_LABELS[phase] - ? t(YOUTUBE_PHASE_LABELS[phase]) - : phase.toUpperCase(), - }); + word(status, 'textContent', () => + t('YOUTUBE {state} · OPEN ORIGINAL', { + state: YOUTUBE_PHASE_LABELS[phase] + ? t(YOUTUBE_PHASE_LABELS[phase]) + : phase.toUpperCase(), + }), + ); }, }); } @@ -1165,6 +1212,8 @@ export function createBhoteKoshiEmbeddedMedia({ } function destroy() { + offLocale(); + worded.clear(); hide({ immediate: true }); for (const current of [...leavingRecords]) removeRecord(current); clearWarm(); diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index e007d9eb2..8211cebb5 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -25,7 +25,7 @@ import { } from './bhoteKoshiEmbeddedMedia.js'; import { acquireImageryComparison } from '../maps/imageryComparison.js'; import { createImagerySplit } from '../ui/imagerySplit.js'; -import { formatDate, t, tn, msg } from '../i18n/index.js'; +import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js'; import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js'; import { applicationPinia } from '../ui/vueRuntime.js'; @@ -1006,8 +1006,10 @@ export function createBhoteKoshiEventLayer({ let _panelRefs = null; let _panelOwner = null; let _panelPublished = ''; - // The dates and field reports, which stay as they were when the panel opened. + // The dates and field reports, which stay as they were when the panel + // opened, until a locale change words them again. let _panelFixed = null; + let _offPanelLocale = null; let _splitControl = null; let _beforeLayer = null; let _afterLayer = null; @@ -2604,9 +2606,9 @@ export function createBhoteKoshiEventLayer({ id: 'bhote-koshi-split-line', handleClass: 'bhote-koshi-split-handle', cssProperty: '--bhote-koshi-split', - beforeTitle: imageryEndpointText(event.imagery.before), - afterTitle: imageryEndpointText(event.imagery.after), - ariaLabel: t('Historical reference and post-event image divider'), + beforeTitle: () => imageryEndpointText(event.imagery.before), + afterTitle: () => imageryEndpointText(event.imagery.after), + ariaLabel: () => t('Historical reference and post-event image divider'), formatValueText: (beforePercent, afterPercent) => t( 'A historical reference {before} percent, B post-event {after} percent', @@ -2666,6 +2668,19 @@ export function createBhoteKoshiEventLayer({ }); } + /** + * Word the panel again in the current locale: fresh records, written by + * the same syncs that keep them current. + */ + function relocalizePanel() { + if (!_panelRefs || !_event) return; + _panelRefs = createPanelRecords(_event); + _panelFixed = null; + writeCinematicButton(); + syncPanel(); + syncPresentationMode(); + } + function syncCinematicButton() { if (writeCinematicButton()) publishPanel(); } @@ -3517,6 +3532,8 @@ export function createBhoteKoshiEventLayer({ ), ); publishPanel(); + _offPanelLocale?.(); + _offPanelLocale = onLocaleChange(relocalizePanel); syncPresentationMode(); overlayHost.setVisible( BHOTE_KOSHI_OVERLAY_SOURCE_ID, @@ -3613,6 +3630,8 @@ export function createBhoteKoshiEventLayer({ panelStore.connect(null); panelStore.publish(null); } + _offPanelLocale?.(); + _offPanelLocale = null; _panelOwner = null; _panelRefs = null; _panelPublished = ''; diff --git a/src/hud.js b/src/hud.js index e93cf34d5..80ef5e6f8 100644 --- a/src/hud.js +++ b/src/hud.js @@ -37,7 +37,7 @@ import { DEFAULT_HUD_LAYOUT, normalizeHudLayout, } from './hudLayouts.js'; -import { formatNumber, msg, t } from './i18n/index.js'; +import { formatNumber, msg, onLocaleChange, t } from './i18n/index.js'; import { localizeLayerName } from './ui/layerPanel.js'; import { isEmbeddedInline } from './app/embed.js'; import { subscribeSelectedVessel } from './hudReadouts.js'; @@ -283,9 +283,30 @@ export class IntelHUD { ); this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); + this._offLocale = onLocaleChange(() => this._relocalize()); this._startTimers(); } + /** + * Show the HUD's own words in the new locale. The deterministic summary + * is composed again; the AI reply does not depend on the locale, so the + * next tick shows the one already received rather than asking again. + */ + _relocalize() { + this._present({ + mode: MODE_LABELS[this._currentStyle] + ? t(MODE_LABELS[this._currentStyle]) + : this._currentStyle.toUpperCase(), + summaryLabel: t('SUMMARY'), + rec: t('REC'), + ...(this._view.vessel.active + ? {} + : { vessel: { active: false, text: t('AIS: --') } }), + }); + if (this._visible && this._latestMetrics) this._updateCameraData(); + this._setSummaryText(this._composeSummary(), false); + } + /** * Present the HUD's first view: corner brackets, classification banners, * sensor readouts, edge metadata strips and the bottom summary bar, which @@ -1081,5 +1102,6 @@ export class IntelHUD { this._dataManagerUnsubscribe?.(); this._vesselUnsubscribe?.(); this._summaryRequest?.abort(); + this._offLocale(); } } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 68541e282..26846c70a 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -7,7 +7,8 @@ import { formatNumber, formatRelativeTime, msg, - persistLocaleAndReload, + onLocaleChange, + persistLocale, setLocale, t, tn, @@ -258,6 +259,40 @@ test('applyDocumentTranslations translates opted-in text and attributes in place assert.equal(label.textContent, t('POWERED UP')); assert.equal(pin.attrs.get('title'), t('POWERED UP')); assert.equal(unknown.textContent, 'No such message'); + // A later pass starts from the English source, so every switch applies. + setLocale('de'); + applyDocumentTranslations(doc); + assert.equal(label.textContent, t('POWERED UP')); + setLocale('en'); + assert.equal(applyDocumentTranslations(doc), 2, 'English restores'); + assert.equal(label.textContent, 'POWERED UP'); + assert.equal(pin.attrs.get('title'), 'POWERED UP'); + // Text that code wrote since is the new source; a pass leaves it alone. + label.textContent = 'Error: offline'; + setLocale('ru'); + applyDocumentTranslations(doc); + assert.equal(label.textContent, 'Error: offline'); +}); + +test('onLocaleChange listeners hear each change until removed', () => { + const heard = []; + const off = onLocaleChange(() => heard.push(t('POWERED UP'))); + const broken = onLocaleChange(() => { + throw new Error('broken listener'); + }); + const error = console.error; + console.error = () => {}; + try { + setLocale('ru'); + setLocale('ru'); + assert.deepEqual(heard, [t('POWERED UP')], 'once, in the new locale'); + off(); + setLocale('en'); + assert.equal(heard.length, 1); + } finally { + console.error = error; + broken(); + } }); test('applyDocumentTranslations reads data-i18n-context for element text', () => { @@ -275,13 +310,9 @@ test('applyDocumentTranslations reads data-i18n-context for element text', () => assert.notEqual(option.textContent, t('All')); }); -test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => { +test('persistLocale keeps the choice in ?lang when storage is blocked', () => { const calls = []; - const location = { - href: 'https://gev.local/?x=1#scene=a', - assign: (url) => calls.push(['assign', url]), - reload: () => calls.push(['reload']), - }; + const location = { href: 'https://gev.local/?x=1#scene=a' }; const history = { replaceState: (_s, _t, url) => calls.push(['replace', url]), }; @@ -290,36 +321,30 @@ test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', throw new Error('blocked'); }, }; - assert.equal( - persistLocaleAndReload('ru', { location, history, storage }), - true, - ); + assert.equal(persistLocale('ru', { location, history, storage }), 'ru'); assert.deepEqual(calls, [ ['replace', 'https://gev.local/?x=1&lang=ru#scene=a'], - ['reload'], ]); }); -test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => { +test('persistLocale stores the choice, keeps the hash, strips ?lang', () => { const storage = memoryStorage(); const calls = []; - const location = { - href: 'https://gev.local/?lang=en&x=1#scene=a', - assign: (url) => calls.push(['assign', url]), - reload: () => calls.push(['reload']), - }; + const location = { href: 'https://gev.local/?lang=en&x=1#scene=a' }; const history = { replaceState: (_s, _t, url) => calls.push(['replace', url]), }; - assert.equal( - persistLocaleAndReload('ru', { location, history, storage }), - true, - ); + assert.equal(persistLocale('ru', { location, history, storage }), 'ru'); assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru'); - assert.deepEqual(calls, [ - ['replace', 'https://gev.local/?x=1#scene=a'], - ['reload'], - ]); + assert.deepEqual(calls, [['replace', 'https://gev.local/?x=1#scene=a']]); + // An address without ?lang is left as it is. + calls.length = 0; + persistLocale('de', { + location: { href: 'https://gev.local/#scene=a' }, + history, + storage, + }); + assert.deepEqual(calls, []); }); test('tn() shows a caller-formatted count and still picks the form by number', () => { diff --git a/src/i18n/index.js b/src/i18n/index.js index a5a140e6e..d785c2b86 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -53,6 +53,37 @@ let pageLocale = { value: DEFAULT_LOCALE }; export function bindLocale(locale) { locale.value = pageLocale.value; pageLocale = locale; + bound = true; +} + +let bound = false; +const localeListeners = new Set(); + +/** + * Call `listener` after the active locale changes, so code that shows text it + * translated itself (a status line, a count, a time) shows it again. Text a + * Vue view translates while rendering follows the locale without this. + * @param {() => void} listener + * @returns {() => void} Stops listening. + */ +export function onLocaleChange(listener) { + if (typeof listener !== 'function') return () => {}; + localeListeners.add(listener); + return () => localeListeners.delete(listener); +} + +/** + * Tell the listeners the locale changed. setLocale does this for an unbound + * locale; the owner of a bound ref calls it when the ref changes. + */ +export function notifyLocaleChange() { + for (const listener of [...localeListeners]) { + try { + listener(); + } catch (error) { + console.error('[i18n] locale listener failed', error); + } + } } /** The catalog for a locale; null for English and unknown codes. */ @@ -74,8 +105,9 @@ export function getLocale() { } /** - * Switch the active locale. Pure state change: call applyDocumentLanguage / - * applyDocumentTranslations to reflect it in the DOM. + * Switch the active locale. Views re-render and onLocaleChange listeners + * word their text again; the application keeps / and + * the page's data-i18n markup in step. * @returns {string} The locale actually applied (normalized). */ export function setLocale(locale) { @@ -83,6 +115,7 @@ export function setLocale(locale) { if (normalized !== pageLocale.value) { pageLocale.value = normalized; formatterCache.clear(); + if (!bound) notifyLocaleChange(); } return normalized; } @@ -276,75 +309,57 @@ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ ['data-i18n-placeholder', 'placeholder'], ]); +// Per element, the English source a pass read and the text it wrote, so a +// later pass in another locale, English included, starts from the source +// rather than from that text. Text that code wrote since is the new source. +const documentSources = new WeakMap(); + /** - * Translate every opted-in element in `doc`. Runs once at boot; switching - * locale reloads the page. + * Translate every opted-in element in `doc` into the active locale. Runs at + * boot and again after every locale change (the page's own markup, such as + * a host's, is not rendered by a view). * @param {Document|Element} doc * @returns {number} Count of element/attribute writes performed. */ export function applyDocumentTranslations(doc) { if (typeof doc?.querySelectorAll !== 'function') return 0; - if (!CATALOGS[getLocale()]) return 0; let applied = 0; for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) { for (const element of doc.querySelectorAll(`[${marker}]`)) { - const source = attribute - ? element.getAttribute(attribute) - : normalizeMarkupText(element.textContent); + const read = () => + attribute + ? element.getAttribute(attribute) + : normalizeMarkupText(element.textContent); + let sources = documentSources.get(element); + if (!sources) documentSources.set(element, (sources = {})); + const shown = read(); + const known = sources[marker]; + const source = known && shown === known.written ? known.source : shown; + sources[marker] = { source, written: shown }; if (!source) continue; const context = attribute ? null : element.getAttribute?.('data-i18n-context'); const text = context ? t(source, { context }) : t(source); - if (text === source) continue; + if (text === shown) continue; if (attribute) element.setAttribute(attribute, text); else element.textContent = text; + sources[marker].written = read(); applied += 1; } } 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. + * Store a locale choice for the next visit and keep the address in step + * without reloading: ?lang is dropped once the choice is stored, or carries + * the choice where storage is blocked (share links drop it: + * ShareLinkManager.copyLink). The hash is kept exactly. setLocale applies + * the choice to the page. + * @returns {string} The normalized locale. */ -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 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( +export function persistLocale( locale, { location = globalThis.location, @@ -354,28 +369,13 @@ export function persistLocaleAndReload( ) { const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; const stored = writeStoredLocale(normalized, storage); - if (!location || typeof location.assign !== 'function') return false; - let target = location.href; try { - // Without storage the choice only survives in the URL, so keep it there. const url = new URL(location.href); if (stored) url.searchParams.delete('lang'); else url.searchParams.set('lang', normalized); - target = url.href; + if (url.href !== location.href) history?.replaceState?.(null, '', url.href); } catch { - // Keep the current href. + // Without a location or history the stored choice still applies. } - // location.assign() to the identical URL does not navigate, so strip ?lang - // in place and force a real reload instead. - try { - history?.replaceState?.(null, '', target); - } catch { - // replaceState is best-effort; the reload below still applies. - } - if (typeof location.reload === 'function') { - location.reload(); - } else { - location.assign(target); - } - return true; + return normalized; } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 3c6d8a2d8..c1b7db3e5 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -872,7 +872,6 @@ "That line has no length — click somewhere further away.": "Diese Linie hat keine Länge – weiter entfernt klicken.", " · {max}-point limit reached": " · Limit von {max} Punkten erreicht", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · Doppelklick oder Enter beendet, Rücktaste macht rückgängig, Esc bricht ab.{full}", - "drawings on the map": "Zeichnungen auf der Karte", "That shape already has {count} points — finish it or press Backspace.": "Diese Form hat bereits {count} Punkte – abschließen oder Rücktaste drücken.", "That point is off the globe — click on the world.": "Dieser Punkt liegt außerhalb des Globus – auf die Erde klicken.", "That shape could not be placed.": "Diese Form konnte nicht platziert werden.", @@ -1173,7 +1172,6 @@ "GET KEY ↗": "SCHLÜSSEL HOLEN ↗", "{envVar} saved — paste to replace": "{envVar} gespeichert – zum Ersetzen einfügen", "paste {envVar}": "{envVar} einfügen", - "unsaved provider keys": "ungespeicherte Anbieter-Schlüssel", "Project keys: {status}": "Projektschlüssel: {status}", "Cloud voice will use OPENAI_API_KEY on the next session.": "Die Cloud-Sprachsteuerung verwendet ab der nächsten Sitzung OPENAI_API_KEY.", "Checking local ChatGPT OAuth sign-in…": "Lokale ChatGPT-OAuth-Anmeldung wird geprüft…", @@ -2049,9 +2047,6 @@ "Sensitivity": "Empfindlichkeit", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "ungespeicherte CCTV-Kalibrierung", - "the open cockpit": "das geöffnete Cockpit", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Ein Sprachwechsel lädt die Seite neu. Dabei geht Folgendes verloren: {items}. Trotzdem wechseln?", "Link shared": "Link geteilt", "Return map to straight-down view": "Karte wieder senkrecht von oben zeigen", "Tilt map to oblique view": "Karte in Schrägansicht kippen", @@ -2098,7 +2093,6 @@ "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL", "Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `", - "imported map files": "importierte Kartendateien", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.", "{count} map objects": { "one": "{count} Kartenobjekt", @@ -2678,7 +2672,6 @@ "Activate to toggle voice": "Aktivieren, um die Sprachsteuerung umzuschalten", "Voice active": "Sprachsteuerung aktiv", "Voice could not be started.": "Sprachsteuerung konnte nicht gestartet werden.", - "the active voice session": "die aktive Sprachsitzung", "{value} feet": "{value} Fuß", "Fire · FRP {frp} MW": "Brand · FRP {frp} MW", "The flights layer is not enabled": "Die Ebene „Live-Flüge“ ist nicht aktiviert", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3693b127f..6e3acde69 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2466,9 +2466,6 @@ "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", @@ -2654,9 +2651,6 @@ "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", @@ -2857,7 +2851,6 @@ "many": "fuentes {names} no válidas", "other": "fuentes {names} no válidas" }, - "unsaved provider keys": "las claves de proveedores sin guardar", "Exit cockpit to fly to an aircraft": "Sal de la cabina para volar a una aeronave", "Exit cockpit to fly to a satellite": "Sal de la cabina para volar a un satélite", "Exit cockpit to frame contacts": "Sal de la cabina para encuadrar contactos", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a3e967758..2af1499e8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -11,7 +11,6 @@ "That line has no length — click somewhere further away.": "Cette ligne n'a pas de longueur — cliquez plus loin.", " · {max}-point limit reached": " · limite de {max} points atteinte", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · double-clic ou Enter pour terminer, Backspace annule le dernier point, Esc annule.{full}", - "drawings on the map": "les dessins sur la carte", "That shape already has {count} points — finish it or press Backspace.": "Cette forme a déjà {count} points — terminez-la ou appuyez sur Backspace.", "That point is off the globe — click on the world.": "Ce point est hors du globe — cliquez sur la Terre.", "That shape could not be placed.": "Impossible de placer cette forme.", @@ -402,7 +401,6 @@ "paste {envVar}": "collez {envVar}", "REMOVE": "SUPPRIMER", "Remove {title} from this app's saved keys": "Supprimer {title} des clés enregistrées de l'application", - "unsaved provider keys": "les clés de fournisseurs non enregistrées", "Project keys: {status}": "Clés du projet : {status}", "your app configuration": "la configuration de votre application", "your local .env": "votre .env local", @@ -1540,9 +1538,6 @@ "Sensitivity": "Sensibilité", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "la calibration CCTV non enregistrée", - "the open cockpit": "le cockpit ouvert", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Changer de langue recharge la page. Ceci sera perdu : {items}. Changer quand même ?", "VISUAL PRESETS": "PRÉRÉGLAGES VISUELS", "LOCATION": "LIEU", "CONTACTS — nearest planes · vessels · sites": "CONTACTS — avions, navires et sites les plus proches", @@ -1767,7 +1762,6 @@ "Space Missions": "Missions spatiales", "Context": "Contexte", "Rendered globe frames per second · toggle with `": "Images du globe rendues par seconde · basculer avec `", - "imported map files": "les fichiers importés sur la carte", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossible d'actualiser les altitudes du sol. Réessayez avec le fichier une fois la carte chargée.", "{count} map objects": { "one": "{count} objet cartographique", @@ -2547,7 +2541,6 @@ "Activate to toggle voice": "Activer/désactiver la commande vocale", "Voice active": "Voix active", "Voice could not be started.": "Impossible de démarrer la voix.", - "the active voice session": "la session vocale active", "{value} feet": "{value} pieds", "Fire · FRP {frp} MW": "Feu · FRP {frp} MW", "The flights layer is not enabled": "La couche des vols n'est pas activée", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 73cfdb2c4..aed8d0003 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -11,7 +11,6 @@ "That line has no length — click somewhere further away.": "Quella linea non ha lunghezza: fai clic più lontano.", " · {max}-point limit reached": " · raggiunto il limite di {max} punti", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · doppio clic o Enter per finire, Backspace torna indietro, Esc annulla.{full}", - "drawings on the map": "disegni sulla mappa", "That shape already has {count} points — finish it or press Backspace.": "Quella forma ha già {count} punti: finiscila o premi Backspace.", "That point is off the globe — click on the world.": "Quel punto è fuori dal globo: fai clic sul mondo.", "That shape could not be placed.": "Impossibile posizionare quella forma.", @@ -402,7 +401,6 @@ "paste {envVar}": "incolla {envVar}", "REMOVE": "RIMUOVI", "Remove {title} from this app's saved keys": "Rimuovi {title} dalle chiavi salvate di questa app", - "unsaved provider keys": "chiavi provider non salvate", "Project keys: {status}": "Chiavi del progetto: {status}", "your app configuration": "la configurazione della tua app", "your local .env": "il tuo .env locale", @@ -1540,9 +1538,6 @@ "Sensitivity": "Sensibilità", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "calibrazione CCTV non salvata", - "the open cockpit": "il cockpit aperto", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Cambiare lingua ricarica la pagina. Perderai: {items}. Cambiare comunque?", "VISUAL PRESETS": "PRESET VISIVI", "LOCATION": "POSIZIONE", "CONTACTS — nearest planes · vessels · sites": "CONTATTI — aerei · navi · siti più vicini", @@ -1767,7 +1762,6 @@ "Space Missions": "Missioni spaziali", "Context": "Contesto", "Rendered globe frames per second · toggle with `": "Fotogrammi al secondo del globo · attiva/disattiva con `", - "imported map files": "file di mappa importati", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Impossibile aggiornare le quote del terreno. Riprova con il file quando la mappa è caricata.", "{count} map objects": { "one": "{count} oggetto sulla mappa", @@ -2547,7 +2541,6 @@ "Activate to toggle voice": "Attiva per abilitare o disabilitare la voce", "Voice active": "Voce attiva", "Voice could not be started.": "Impossibile avviare la voce.", - "the active voice session": "la sessione vocale attiva", "{value} feet": "{value} piedi", "Fire · FRP {frp} MW": "Incendio · FRP {frp} MW", "The flights layer is not enabled": "Il livello voli non è attivo", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2968f050f..3d8e084ed 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -9,7 +9,6 @@ "That line has no length — click somewhere further away.": "線の長さがゼロです。もっと離れた場所をクリックしてください。", " · {max}-point limit reached": " · 上限の{max}点に達しました", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · ダブルクリックまたはEnterで完了、Backspaceで元に戻す、Escでキャンセル。{full}", - "drawings on the map": "地図上の描画", "That shape already has {count} points — finish it or press Backspace.": "この図形はすでに{count}点あります。完了するか、Backspaceを押してください。", "That point is off the globe — click on the world.": "その点は地球の外です。地表をクリックしてください。", "That shape could not be placed.": "図形を配置できませんでした。", @@ -394,7 +393,6 @@ "paste {envVar}": "{envVar}を貼り付け", "REMOVE": "削除", "Remove {title} from this app's saved keys": "{title}をこのアプリの保存済みキーから削除", - "unsaved provider keys": "未保存のプロバイダーキー", "Project keys: {status}": "プロジェクトのキー:{status}", "your app configuration": "アプリの設定", "your local .env": "ローカルの .env", @@ -1498,9 +1496,6 @@ "Sensitivity": "感度", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "未保存のCCTVキャリブレーション", - "the open cockpit": "表示中のコックピット", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "言語を切り替えるとページが再読み込みされます。次の内容は失われます:{items}。切り替えますか?", "VISUAL PRESETS": "ビジュアルプリセット", "LOCATION": "位置", "CONTACTS — nearest planes · vessels · sites": "コンタクト:最寄りの航空機 · 船舶 · 施設", @@ -1711,7 +1706,6 @@ "Space Missions": "宇宙ミッション", "Context": "コンテキスト", "Rendered globe frames per second · toggle with `": "地球儀の描画フレームレート(毎秒) · `キーで切り替え", - "imported map files": "インポートした地図ファイル", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "地表高を更新できませんでした。地図の読み込み後に、もう一度このファイルをお試しください。", "{count} map objects": { "other": "地図オブジェクト{count}個" @@ -2417,7 +2411,6 @@ "Activate to toggle voice": "押すと音声のオン/オフを切り替え", "Voice active": "音声 オン", "Voice could not be started.": "音声を開始できませんでした。", - "the active voice session": "進行中の音声セッション", "{value} feet": "{value}フィート", "Fire · FRP {frp} MW": "火災 · FRP {frp} MW", "The flights layer is not enabled": "フライトレイヤーが有効になっていません", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 82f306093..7af4ae72c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -11,7 +11,6 @@ "That line has no length — click somewhere further away.": "Essa linha não tem comprimento — clique em um ponto mais distante.", " · {max}-point limit reached": " · limite de {max} pontos atingido", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · clique duplo ou Enter para concluir, Backspace desfaz, Esc cancela.{full}", - "drawings on the map": "os desenhos no mapa", "That shape already has {count} points — finish it or press Backspace.": "Essa forma já tem {count} pontos — conclua-a ou pressione Backspace.", "That point is off the globe — click on the world.": "Esse ponto está fora do globo — clique sobre o mundo.", "That shape could not be placed.": "Não foi possível colocar essa forma.", @@ -402,7 +401,6 @@ "paste {envVar}": "cole {envVar}", "REMOVE": "REMOVER", "Remove {title} from this app's saved keys": "Remover {title} das chaves salvas deste app", - "unsaved provider keys": "chaves de provedores não salvas", "Project keys: {status}": "Chaves do projeto: {status}", "your app configuration": "sua configuração do app", "your local .env": "seu .env local", @@ -1540,9 +1538,6 @@ "Sensitivity": "Sensibilidade", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "a calibração de CCTV não salva", - "the open cockpit": "a cabine aberta", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Mudar de idioma recarrega a página. Isto será perdido: {items}. Mudar mesmo assim?", "VISUAL PRESETS": "PREDEF. VISUAIS", "LOCATION": "LOCAL", "CONTACTS — nearest planes · vessels · sites": "CONTATOS — aviões · embarcações · locais mais próximos", @@ -1767,7 +1762,6 @@ "Space Missions": "Missões espaciais", "Context": "Contexto", "Rendered globe frames per second · toggle with `": "Quadros por segundo renderizados do globo · alterne com `", - "imported map files": "os arquivos importados no mapa", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Não foi possível atualizar as altitudes do terreno. Tente o arquivo de novo quando o mapa tiver carregado.", "{count} map objects": { "one": "{count} objeto no mapa", @@ -2549,7 +2543,6 @@ "Activate to toggle voice": "Ative para ligar ou desligar a voz", "Voice active": "Voz ativa", "Voice could not be started.": "Não foi possível iniciar a voz.", - "the active voice session": "a sessão de voz ativa", "{value} feet": "{value} pés", "Fire · FRP {frp} MW": "Incêndio · FRP {frp} MW", "The flights layer is not enabled": "A camada de voos não está ativada", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 746288a94..a7d2e3483 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2508,9 +2508,6 @@ "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": "подключите приёмник в разделе «Радио»", @@ -2712,9 +2709,6 @@ "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": "Турбовинтовой самолёт", @@ -2925,7 +2919,6 @@ "many": "потоки {names} некорректны", "other": "потоки {names} некорректны" }, - "unsaved provider keys": "несохранённые ключи провайдеров", "Exit cockpit to fly to an aircraft": "Выйдите из кабины, чтобы перелететь к самолёту", "Exit cockpit to fly to a satellite": "Выйдите из кабины, чтобы перелететь к спутнику", "Exit cockpit to frame contacts": "Выйдите из кабины, чтобы показать контакты в кадре", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index c27ea6332..47326d900 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -12,7 +12,6 @@ "That line has no length — click somewhere further away.": "Лінія не має довжини — клацніть далі.", " · {max}-point limit reached": " · досягнуто ліміту точок: {max}", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · подвійне клацання або Enter — завершити, Backspace — скасувати точку, Esc — скасувати.{full}", - "drawings on the map": "малюнки на карті", "That shape already has {count} points — finish it or press Backspace.": "Фігура вже має максимальну кількість точок ({count}) — завершіть її або натисніть Backspace.", "That point is off the globe — click on the world.": "Ця точка поза глобусом — клацніть на Землі.", "That shape could not be placed.": "Не вдалося розмістити фігуру.", @@ -406,7 +405,6 @@ "paste {envVar}": "вставте {envVar}", "REMOVE": "ВИДАЛИТИ", "Remove {title} from this app's saved keys": "Видалити {title} зі збережених ключів застосунку", - "unsaved provider keys": "незбережені ключі постачальників", "Project keys: {status}": "Ключі проєкту: {status}", "your app configuration": "вашої конфігурації застосунку", "your local .env": "вашого локального .env", @@ -1560,9 +1558,6 @@ "Sensitivity": "Чутливість", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "незбережене калібрування CCTV", - "the open cockpit": "відкрита кабіна", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "Зміна мови перезавантажить сторінку. Буде втрачено: {items}. Усе одно змінити мову?", "VISUAL PRESETS": "ВІЗУАЛЬНІ ПРЕСЕТИ", "LOCATION": "РОЗТАШУВАННЯ", "CONTACTS — nearest planes · vessels · sites": "КОНТАКТИ — найближчі літаки · судна · обʼєкти", @@ -1794,7 +1789,6 @@ "Space Missions": "Космічні місії", "Context": "Контекст", "Rendered globe frames per second · toggle with `": "Кадрів глобуса за секунду · перемикання клавішею `", - "imported map files": "імпортовані файли на карті", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Не вдалося оновити висоти рельєфу. Спробуйте імпортувати файл ще раз, коли карта завантажиться.", "{count} map objects": { "one": "{count} обʼєкт на карті", @@ -2611,7 +2605,6 @@ "Activate to toggle voice": "Активуйте, щоб увімкнути або вимкнути голос", "Voice active": "Голос активний", "Voice could not be started.": "Не вдалося запустити голос.", - "the active voice session": "активний голосовий сеанс", "{value} feet": "{value} фт", "Fire · FRP {frp} MW": "Пожежа · FRP {frp} МВт", "The flights layer is not enabled": "Шар рейсів не ввімкнено", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index dbffef805..65cb05f97 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -9,7 +9,6 @@ "That line has no length — click somewhere further away.": "这条线没有长度——请在更远处点击。", " · {max}-point limit reached": " · 已达 {max} 个点上限", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 双击或按 Enter 完成,按 Backspace 撤销,按 Esc 取消。{full}", - "drawings on the map": "地图上的绘图", "That shape already has {count} points — finish it or press Backspace.": "该形状已有 {count} 个点——请完成绘制或按 Backspace。", "That point is off the globe — click on the world.": "该点在地球之外——请点击地球上的位置。", "That shape could not be placed.": "该形状无法放置。", @@ -394,7 +393,6 @@ "paste {envVar}": "粘贴 {envVar}", "REMOVE": "移除", "Remove {title} from this app's saved keys": "从本应用已保存的密钥中移除 {title}", - "unsaved provider keys": "未保存的服务商密钥", "Project keys: {status}": "项目密钥:{status}", "your app configuration": "你的应用配置", "your local .env": "你的本地 .env", @@ -1470,9 +1468,6 @@ "Sensitivity": "灵敏度", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "未保存的 CCTV 校准", - "the open cockpit": "当前打开的座舱", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切换语言会重新加载页面。以下内容将会丢失:{items}。仍要切换吗?", "VISUAL PRESETS": "视觉预设", "LOCATION": "位置", "CONTACTS — nearest planes · vessels · sites": "目标:最近的飞机 · 船舶 · 设施", @@ -1686,7 +1681,6 @@ "Space Missions": "太空任务", "Context": "态势", "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", - "imported map files": "已导入的地图文件", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "无法刷新地面高度。请在地图加载完成后重新尝试该文件。", "{count} map objects": { "other": "{count} 个地图对象" @@ -2315,7 +2309,6 @@ "Activate to toggle voice": "点击切换语音", "Voice active": "语音已启用", "Voice could not be started.": "无法启动语音。", - "the active voice session": "当前语音会话", "{value} feet": "{value} 英尺", "The flights layer is not enabled": "航班图层未开启", "The military layer is not enabled": "军用航班图层未开启", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index f3f89690e..e292ff7b5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -9,7 +9,6 @@ "That line has no length — click somewhere further away.": "這條線沒有長度——請在更遠處點擊。", " · {max}-point limit reached": " · 已達 {max} 個點上限", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 雙擊或按 Enter 完成,按 Backspace 撤銷,按 Esc 取消。{full}", - "drawings on the map": "地圖上的繪圖", "That shape already has {count} points — finish it or press Backspace.": "該形狀已有 {count} 個點——請完成繪製或按 Backspace。", "That point is off the globe — click on the world.": "該點在地球之外——請點擊地球上的位置。", "That shape could not be placed.": "該形狀無法放置。", @@ -394,7 +393,6 @@ "paste {envVar}": "粘貼 {envVar}", "REMOVE": "移除", "Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}", - "unsaved provider keys": "未保存的服務商密鑰", "Project keys: {status}": "項目密鑰:{status}", "your app configuration": "你的應用配置", "your local .env": "你的本地 .env", @@ -1465,9 +1463,6 @@ "Sensitivity": "靈敏度", "WHOT/BHOT": "WHOT/BHOT", "Ironbow": "Ironbow", - "unsaved CCTV calibration": "未保存的 CCTV 校準", - "the open cockpit": "當前打開的座艙", - "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新加載頁面。以下內容將會丟失:{items}。仍要切換嗎?", "VISUAL PRESETS": "視覺預設", "LOCATION": "位置", "CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施", @@ -1676,7 +1671,6 @@ "Space Missions": "太空任務", "Context": "態勢", "Rendered globe frames per second · toggle with `": "每秒渲染的地球幀數 · 按 ` 鍵切換", - "imported map files": "已導入的地圖文件", "Ground heights could not be refreshed. Try the file again when the map has loaded.": "無法刷新地面高度。請在地圖加載完成後重新嘗試該文件。", "{count} map objects": { "other": "{count} 個地圖對象" @@ -2303,7 +2297,6 @@ "Activate to toggle voice": "點擊切換語音", "Voice active": "語音已啟用", "Voice could not be started.": "無法啟動語音。", - "the active voice session": "當前語音會話", "{value} feet": "{value} 英尺", "The flights layer is not enabled": "航班圖層未開啟", "The military layer is not enabled": "軍用航班圖層未開啟", diff --git a/src/keySetup.js b/src/keySetup.js index 38f0542c5..dbd7bae7f 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -26,7 +26,7 @@ import { * gev-provider-settings store. */ -import { t, msg, tn, registerUnsavedWork } from './i18n/index.js'; +import { t, msg, tn, onLocaleChange } from './i18n/index.js'; /** * Chip label — pure, exported for tests (keySetup.test.mjs pins the English). @@ -284,16 +284,10 @@ export async function initKeySetup({ auth: { ...state.auth }, }); }; - // Switching language (a reload) asks first while any field holds a key. - const unregisterUnsavedWork = registerUnsavedWork(() => - Object.values(fields).some((value) => String(value ?? '').trim()) - ? msg('unsaved provider keys') - : null, - ); const destroy = () => { if (disposed) return; disposed = true; - unregisterUnsavedWork(); + offLocale(); lifetime.abort(); signal?.removeEventListener('abort', destroy); open = false; @@ -324,6 +318,11 @@ export async function initKeySetup({ publish(); }; + // The chip and rows are translated here; show them again in a new locale. + const offLocale = onLocaleChange(() => { + if (status) render(status); + }); + const visible = () => root.isConnected && root.classList.contains('visible') && diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 0be1bb937..883ee1779 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -8,7 +8,6 @@ import { initKeySetup, } from './keySetup.js'; import { createPinia } from 'pinia'; -import { describeUnsavedWork } from './i18n/index.js'; import { useProviderSettingsStore } from './ui/stores/providerSettings.js'; const freshStore = () => useProviderSettingsStore(createPinia()); @@ -129,31 +128,3 @@ test('aborting pending setup removes its surface and ignores a late response', a assert.equal(await pending, null); assert.equal(store.view.available, false); }); - -test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { - const store = freshStore(); - const root = { dataset: {} }; - const controller = new AbortController(); - const setup = await initKeySetup({ - documentRef: { - getElementById: (id) => (id === 'key-setup' ? root : null), - addEventListener() {}, - removeEventListener() {}, - }, - signal: controller.signal, - store, - fetchImpl: async () => ({ - ok: true, - json: async () => ({ keys: [], setCount: 0, total: 1 }), - }), - }); - assert.ok(setup); - assert.deepEqual(describeUnsavedWork(), []); - store.controls.setField('GOOGLE_MAPS_API_KEY', ' '); - assert.deepEqual(describeUnsavedWork(), []); - store.controls.setField('GOOGLE_MAPS_API_KEY', 'AIza-pasted'); - assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']); - // The field still holds the key, so only teardown dropping the check clears it. - controller.abort(); - assert.deepEqual(describeUnsavedWork(), []); -}); diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 8c7e4b51c..af8f8bd84 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -2,7 +2,12 @@ import { formatAwarenessLabel, AWARENESS_RADIUS_M, } from '../../data/militaryAwarenessEngine.js'; -import { formatNumber, t, translateKnown } from '../../i18n/index.js'; +import { + formatNumber, + onLocaleChange, + t, + translateKnown, +} from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; @@ -73,6 +78,12 @@ export function createPanel({ state: layerState, parts }) { /** The panel shows layer views from the first enable or result onwards. */ function ensurePanel() { layerState.panelShown = true; + // A shown view is worded again in a new locale. + layerState.offPanelLocale ??= onLocaleChange(() => { + const kind = layerState.panelView?.kind; + if (kind === 'results') renderResults(); + else if (kind === 'standby') hidePanel(); + }); } function hidePanel() { @@ -92,6 +103,8 @@ export function createPanel({ state: layerState, parts }) { /** Clear the panel when the layer is destroyed. */ function clearPanel() { if (layerState.panelShown) publish({ kind: 'empty' }); + layerState.offPanelLocale?.(); + layerState.offPanelLocale = null; layerState.panelShown = false; layerState.panelView = null; layerState.panelSignature = ''; diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 88f4dfdbb..1da8f83b6 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { t } from '../../i18n/index.js'; +import { onLocaleChange, t } from '../../i18n/index.js'; import { MISSION_CLOSE_VIEW_RANGE_M, MISSION_GLOBE_VIEW_RANGE_M, @@ -436,12 +436,20 @@ export function createPanel({ state: layerState, services, parts, source }) { preview: scheduleMissionRosterPreview, clear: clearMissionRosterPreviewState, }); + // The roster and mission are worded again in a new locale. + layerState._offMissionLocale = onLocaleChange(() => { + renderMissionRoster(); + updateMissionTelemetry(true, false); + publishPanel(); + }); publishPanel(); } /** Stop presenting; the components return to their initial markup. */ function clearMissionPanel() { layerState._missionPanelReady = false; + layerState._offMissionLocale?.(); + layerState._offMissionLocale = null; layerState._missionRosterPreviewOwnership = null; layerState._missionRosterView = null; layerState._missionRosterSignature = null; diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index c30889018..b874e09fe 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -1,3 +1,5 @@ +import { getLocale } from '../../i18n/index.js'; + export function createPresentation({ state: layerState, services, @@ -10,9 +12,26 @@ export function createPresentation({ return layerState._acceptedCatalogSnapshot; } + /** Build the station categories, labelled in the current locale. */ + + function buildCategories() { + layerState._categories = Object.freeze( + parts.categories + .buildRadioCategories(layerState._stations) + .map((category) => Object.freeze(category)), + ); + layerState._categoriesLocale = getLocale(); + } + /** Return an immutable snapshot consumed by the right-rail UI. */ function getRadioUIState() { + // Labels follow a language switch the next time the state is read. + if ( + layerState._categories.length && + layerState._categoriesLocale !== getLocale() + ) + buildCategories(); const visible = parts.queries.visibleStations(); const selected = parts.queries.selectedStation(); return Object.freeze({ @@ -98,6 +117,7 @@ export function createPresentation({ return { getRadioAcceptedCatalogSnapshot, getRadioUIState, + buildCategories, emitState, subscribeToRadio, subscribeToRadioPlaybackControls, diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 9b4e6b387..d38c79d78 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -295,11 +295,7 @@ export function createRendering({ layerState._stationById = new Map( stations.map((station) => [station.id, station]), ); - layerState._categories = Object.freeze( - parts.categories - .buildRadioCategories(stations) - .map((category) => Object.freeze(category)), - ); + parts.presentation.buildCategories(); if ( layerState._filter === DEFAULT_RADIO_FILTER && !parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER) diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index 918ebb989..e1436f1bb 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -38,6 +38,7 @@ export function createState({ services }) { layerState._stationById = new Map(); layerState._categories = []; + layerState._categoriesLocale = null; layerState._renderById = new Map(); diff --git a/src/maps/controller.js b/src/maps/controller.js index bfdd82033..442c18c0b 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -93,8 +93,10 @@ export class MapSourceController { return Boolean(source && source.available !== false); } _unavailableReason(stack) { + // A source may give its reason as a function, read in the current locale. + const reason = this._sources.get(stack?.id)?.unavailableReason; return ( - this._sources.get(stack?.id)?.unavailableReason || + (typeof reason === 'function' ? reason() : reason) || `${stack?.label || 'This map stack'} is unavailable` ); } diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js index f69b21e31..a12aea29a 100644 --- a/src/maps/defaultSources.js +++ b/src/maps/defaultSources.js @@ -34,17 +34,20 @@ export function createDefaultMapSources({ const common = { descriptor, available: !descriptor.requiresIon || hasIon, + // Read when shown, so it follows the locale. unavailableReason: descriptor.requiresIon - ? t('Needs {envVars} — add it in Provider Settings', { - envVars: keySetupRequirementEnvVars('cesium-ion'), - }) + ? () => + t('Needs {envVars} — add it in Provider Settings', { + envVars: keySetupRequirementEnvVars('cesium-ion'), + }) : null, }; if (descriptor.kind === 'photoreal') return { ...common, available: Boolean(googleTileset), - unavailableReason: photorealUnavailableReason(hasIon || hasGoogle), + unavailableReason: () => + photorealUnavailableReason(hasIon || hasGoogle), tileset: googleTileset, }; const imagery = diff --git a/src/scenes/director.js b/src/scenes/director.js index 180fb3332..051ce89a8 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -29,7 +29,7 @@ import { getSceneAppendRecipeById, } from './recipes.js'; import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js'; -import { t, tn, msg } from '../i18n/index.js'; +import { t, tn, msg, onLocaleChange } from '../i18n/index.js'; import { createSceneDataPacks } from './dataPacks/controller.js'; import { createSceneInteractions } from './interactions.js'; import { createSceneSharing } from './sharing.js'; @@ -176,17 +176,21 @@ export class SceneDirector { /** @type {string|null} Scene whose layer state most recently landed. */ this._loadedSceneId = null; + const readError = this._storageReadError; this._presentation = { - status: this._storageReadError - ? t( - 'Saved project could not be read; storage preserved. Import a valid file to resume saving.', - ) - : t('Ready'), + status: '', progress: 0, runtime: '', playbackActive: false, keyboardEnabled: false, }; + this._setStatus(() => + readError + ? t( + 'Saved project could not be read; storage preserved. Import a valid file to resume saving.', + ) + : t('Ready'), + ); this._state = createStateChannel(() => ({ ...this.getPlaybackStatus(), ...this._presentation, @@ -212,6 +216,12 @@ export class SceneDirector { } } this._initUI(); + // A status the director wrote itself follows the language; one it was + // handed (an error message) stays as it is. + this._offLocale = onLocaleChange(() => { + if (typeof this._statusText === 'function') + this._updateStatus(this._statusText); + }); this._visibilityUnsubscribe = this.dataManager.subscribeVisibilityRequests?.((change) => { if ( @@ -247,6 +257,7 @@ export class SceneDirector { this._interactions?.destroy(); this._dataPacks?.destroy(); this._controls?.destroy(); + this._offLocale?.(); this._state.destroy(); this._destroyPromise = Promise.resolve().then(async () => { this.stopScene(msg('Stopped')); @@ -324,7 +335,7 @@ export class SceneDirector { /** Persist the current project state to localStorage with an updated timestamp. */ _saveProject() { if (this._storageReadError) { - this._toastStorageError( + this._toastStorageError(() => t( 'Scene not saved — existing saved project could not be read. Export edits or import a valid file.', ), @@ -352,22 +363,26 @@ export class SceneDirector { ); this._toastStorageError( invalid - ? t('Scene not saved — {reason}', { - reason: sceneSaveFailureReason(e), - }) + ? () => + t('Scene not saved — {reason}', { + reason: sceneSaveFailureReason(e), + }) : undefined, ); return false; } } - /** Surface a "scene not saved" notice via the global toast + scene status line. */ + /** + * Surface a "scene not saved" notice via the global toast + scene status + * line. `message` words it, so the status line follows the locale. + */ _toastStorageError( - message = t('Scene not saved — browser storage unavailable'), + message = () => t('Scene not saved — browser storage unavailable'), ) { this._updateStatus(message); this._storageToast ??= createToast({ durationMs: 2600 }); - this._storageToast.show(message); + this._storageToast.show(message()); } /** Immutable playback snapshots and completed editing actions. */ @@ -381,10 +396,12 @@ export class SceneDirector { _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) { if (type === 'shot-loaded') - this._presentation.status = t('Loaded: {scene} / {shot}', { - scene: displaySceneTitle(scene), - shot: displayShotTitle(shot.title, shot, scene), - }); + this._setStatus(() => + t('Loaded: {scene} / {shot}', { + scene: displaySceneTitle(scene), + shot: displayShotTitle(shot.title, shot, scene), + }), + ); this._publish({ type, sceneId: scene.id, @@ -786,7 +803,7 @@ export class SceneDirector { (shot, index) => shot.title === requiredShotTitles[index], )); if (!inventoryMatches) { - this._updateStatus( + this._updateStatus(() => t('Cannot update {scene}: shot inventory changed', { scene: t(recipe.title), }), @@ -819,7 +836,7 @@ export class SceneDirector { (beatId, index) => beatId === requiredSourcePackBeatIds[index], ); if (!sourcePackMatches) { - this._updateStatus( + this._updateStatus(() => t('Cannot update {scene}: evidence beats changed', { scene: t(recipe.title), }), @@ -844,7 +861,7 @@ export class SceneDirector { shot: resolvePatchedShot(patch), })); if (resolvedPatches.some(({ shot }) => !shot)) { - this._updateStatus( + this._updateStatus(() => t('Cannot update {scene}: shot bindings are incomplete', { scene: t(recipe.title), }), @@ -925,7 +942,7 @@ export class SceneDirector { this._renderShotList(); } if (announce && saved) { - this._updateStatus( + this._updateStatus(() => marker ? tn( patchedShotCount, @@ -960,7 +977,7 @@ export class SceneDirector { const camera = this.styleManager.getCameraState(); if (!camera) { - this._updateStatus(t('Cannot capture shot: camera not ready')); + this._updateStatus(() => t('Cannot capture shot: camera not ready')); return; } @@ -983,7 +1000,7 @@ export class SceneDirector { const saved = this._saveProject(); this._shotOutcome('shot-captured', scene, shot); if (saved) { - this._updateStatus( + this._updateStatus(() => t('Captured: {scene} / {shot}', { scene: displaySceneTitle(scene), shot: displayShotTitle(shot.title, shot, scene), @@ -1002,7 +1019,7 @@ export class SceneDirector { const shot = scene.shots.find((item) => item.id === this._selectedShotId); if (!shot) { - this._updateStatus(t('Select a shot first')); + this._updateStatus(() => t('Select a shot first')); return; } @@ -1018,7 +1035,7 @@ export class SceneDirector { const saved = this._saveProject(); this._shotOutcome('shot-updated', scene, shot); if (saved) { - this._updateStatus( + this._updateStatus(() => t('Updated: {scene} / {shot}', { scene: displaySceneTitle(scene), shot: displayShotTitle(shot.title, shot, scene), @@ -1130,7 +1147,7 @@ export class SceneDirector { const released = await this._releaseSceneLayers(previousScene, token); if (!released || token.cancelled) { if (!token.cancelled) - this._updateStatus( + this._updateStatus(() => t('Could not leave scene: {scene}', { scene: displaySceneTitle(previousScene), }), @@ -1184,7 +1201,7 @@ export class SceneDirector { seeking: true, }); if (this._loadAbort === controller) this._loadAbort = null; - this._updateStatus( + this._updateStatus(() => t('Seeked: {scene} / {shot}', { scene: displaySceneTitle(scene), shot: displayShotTitle(shot.title, shot, scene), @@ -1634,7 +1651,7 @@ export class SceneDirector { running: false, seeking: true, }); - this._updateStatus( + this._updateStatus(() => t('Seeked: {scene} / {shot}', { scene: displaySceneTitle(scene), shot: displayShotTitle(shot.title, shot, scene), @@ -1721,7 +1738,7 @@ export class SceneDirector { this._claimingCamera = false; } if (claimed === false) { - this._updateStatus(t('Camera unavailable — exit cockpit first')); + this._updateStatus(() => t('Camera unavailable — exit cockpit first')); return false; } return true; @@ -1856,7 +1873,7 @@ export class SceneDirector { if (!queue.length) return { started: false, reason: 'scene-complete' }; } if (!queue.length) { - this._updateStatus(t('No shots to run')); + this._updateStatus(() => t('No shots to run')); return { started: false, reason: 'no-shots' }; } @@ -1941,7 +1958,7 @@ export class SceneDirector { releaseOnFinish: preview, }); } catch (error) { - this._updateStatus( + this._updateStatus(() => t('Error: {message}', { message: error.message || 'run failed', }), @@ -2010,7 +2027,7 @@ export class SceneDirector { if (!this._running || !this._runToken) return; this._runToken.cancelled = true; this._runAbort?.abort(); - this._updateStatus(t(reason)); + this._updateStatus(() => t(reason)); this._logEvent('scene_stopped', { reason }); } @@ -2066,7 +2083,7 @@ export class SceneDirector { } if (action.type === 'shot') { if (this._interactionTransitions >= 64) { - this._updateStatus( + this._updateStatus(() => t('Scene transition limit reached — load a shot to reset'), ); return false; @@ -2110,7 +2127,9 @@ export class SceneDirector { try { payload = stringifySceneDocument(this._project); } catch (error) { - this._updateStatus(t('Export failed: {error}', { error: error.message })); + this._updateStatus(() => + t('Export failed: {error}', { error: error.message }), + ); return; } // Trigger a browser download via a temporary anchor element @@ -2123,7 +2142,7 @@ export class SceneDirector { link.click(); link.remove(); URL.revokeObjectURL(url); - this._presentation.status = t('Project exported'); + this._setStatus(() => t('Project exported')); this._publish({ type: 'project-exported', project: this._project }); } @@ -2183,7 +2202,8 @@ export class SceneDirector { this._loadedSceneId = null; const saved = this._saveProject(); this._publish({ type: 'project-imported', project }); - if (saved) this._updateStatus(t('Imported {file}', { file: file.name })); + if (saved) + this._updateStatus(() => t('Imported {file}', { file: file.name })); return true; } catch (error) { if ( @@ -2192,7 +2212,7 @@ export class SceneDirector { generation !== this._importGeneration ) return false; - this._updateStatus( + this._updateStatus(() => error instanceof SceneDocumentError ? t('Import failed: {error}', { error: error.message }) : t('Import failed (could not read JSON file)'), @@ -2302,7 +2322,7 @@ export class SceneDirector { } if (refused.length) { - this._updateStatus( + this._updateStatus(() => t('Layers refused: {layers}', { layers: refused.join(', ') }), ); this._logEvent('shot_layers_refused', { layerIds: [...refused] }); @@ -2394,7 +2414,7 @@ export class SceneDirector { `[Scenes] Could not exit ${mode}:`, result.error || 'unknown reason', ); - this._updateStatus( + this._updateStatus(() => t('Could not exit {mode} — scene layers may be refused', { mode }), ); this._logEvent('context_mode_exit_failed', { @@ -2627,10 +2647,20 @@ export class SceneDirector { * @param {string} text */ _updateStatus(text) { - this._presentation.status = text; + this._setStatus(text); this._publish({ type: 'status-changed' }); } + /** + * Set the status line without publishing. A function is called again when + * the locale changes, so its text follows the language. + * @param {string|(() => string)} text + */ + _setStatus(text) { + this._statusText = text; + this._presentation.status = typeof text === 'function' ? text() : text; + } + /** * Set the runtime label (scene/shot name) and toggle its active class. * @param {string} text - Empty string hides the label diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index aad01054d..80328b040 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1930,7 +1930,7 @@ test('invalid authored edits cannot persist an unreadable project over a good sa try { const before = localStorage.getItem('godsEyeView.sceneProject.v2'); let notice; - director._toastStorageError = (message) => { notice = message; }; + director._toastStorageError = (message) => { notice = message(); }; director._project.scenes[0].shots[0].camera.lat = 91; director._saveProject(); assert.equal(localStorage.getItem('godsEyeView.sceneProject.v2'), before); diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js index e44286988..b24785ac0 100644 --- a/src/scenes/playbackAdapter.js +++ b/src/scenes/playbackAdapter.js @@ -16,7 +16,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) { director._selectedShotId = shot.id; director._renderSceneSelect(); director._renderShotList(); - director._updateStatus( + director._updateStatus(() => t('Running {index}/{total}: {scene} / {shot}', { index: index + 1, total, @@ -79,7 +79,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) { }, complete() { director._setProgress(1); - director._updateStatus(t('Scene run complete')); + director._updateStatus(() => t('Scene run complete')); director._logEvent('scene_run_complete', {}); }, }; diff --git a/src/scenes/sceneTitleDisplay.test.mjs b/src/scenes/sceneTitleDisplay.test.mjs index c476a2000..957f3eec9 100644 --- a/src/scenes/sceneTitleDisplay.test.mjs +++ b/src/scenes/sceneTitleDisplay.test.mjs @@ -94,7 +94,9 @@ test('the Scene status line shows an operator-typed shot title as typed', async const director = { _renderSceneSelect() {}, _renderShotList() {}, - _updateStatus: (text) => statuses.push(text), + // The director words its status through a function (it follows the locale). + _updateStatus: (text) => + statuses.push(typeof text === 'function' ? text() : text), _logEvent() {}, }; const adapter = createScenePlaybackAdapter(director, 4); diff --git a/src/scenes/sharing.js b/src/scenes/sharing.js index e418c0318..9b3878fef 100644 --- a/src/scenes/sharing.js +++ b/src/scenes/sharing.js @@ -196,7 +196,7 @@ export function createSceneSharing(director) { const scene = director._getSelectedScene(), shot = scene?.shots.find((s) => s.id === director._selectedShotId); if (!scene || !shot) { - director._updateStatus(t('Select a scene and shot first')); + director._updateStatus(() => t('Select a scene and shot first')); return; } const owner = open(t('Edit scene details')); @@ -340,7 +340,7 @@ export function createSceneSharing(director) { director._selectedSceneId, ); } catch { - director._updateStatus(t('Select a scene first')); + director._updateStatus(() => t('Select a scene first')); return; } const owner = open(t('Share selected scene')); diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 260ad6647..8d7707e95 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -17,7 +17,13 @@ import { selectAuthorizedSdrDevice, } from './usbDevices.js'; import { summarizeLocalAdsb } from '../sources/adsbRecords.js'; -import { formatNumber, msg, t, translateKnown } from '../i18n/index.js'; +import { + formatNumber, + msg, + onLocaleChange, + t, + translateKnown, +} from '../i18n/index.js'; export { createRememberingWebUsb } from './usbDevices.js'; @@ -302,20 +308,29 @@ function describeConnectionError(error) { return message || msg('RTL-SDR connection failed'); } +/** State fields `_setState` keeps as functions to word again (see _reword). */ +const WORDED_FIELDS = ['message', 'seekMessage']; + /** * State fields for a receiver error: `message` in the UI language for the * Radio card, `errorText` as English data for the Local ADS-B layer's stats. * A new connection, a mode switch and stop() clear `errorText` again. * @param {string} text English error text (ours or the browser's). */ + function errorState(text) { - return { status: 'error', message: translateKnown(text), errorText: text }; + return { + status: 'error', + message: () => translateKnown(text), + errorText: text, + }; } function streamingMessage(mode) { - return mode === 'adsb' - ? t('Receiving 1090 MHz ADS-B') - : t('Receiving broadcast FM'); + return () => + mode === 'adsb' + ? t('Receiving 1090 MHz ADS-B') + : t('Receiving broadcast FM'); } /** @@ -351,9 +366,7 @@ export class SdrController { webUsbSupported, connected: false, status: webUsbSupported ? 'idle' : 'unsupported', - message: webUsbSupported - ? t('Connect an RTL-SDR to begin.') - : t('WebUSB is unavailable in this browser'), + message: '', mode: 'fm', frequencyHz: FM_DEFAULT_HZ, sampleRate: FM_SAMPLE_RATE, @@ -378,6 +391,14 @@ export class SdrController { iqLevelDbfs: null, audioLevelDbfs: null, }; + this._wording = { + message: webUsbSupported + ? () => t('Connect an RTL-SDR to begin.') + : () => t('WebUSB is unavailable in this browser'), + seekMessage: null, + }; + this.state.message = this._wording.message(); + this._offLocale = null; this._listeners = new Set(); this._provider = null; this._device = null; @@ -473,12 +494,33 @@ export class SdrController { subscribe(listener) { if (typeof listener !== 'function') return () => {}; + // Messages are worded again in a new locale while anyone listens; a + // first listener after a change gets them in the current one. + if (!this._offLocale) { + this._wordState(); + this._offLocale = onLocaleChange(() => this._reword()); + } this._listeners.add(listener); listener(this.getState()); - return () => this._listeners.delete(listener); + return () => { + this._listeners.delete(listener); + if (this._listeners.size) return; + this._offLocale?.(); + this._offLocale = null; + }; } + /** + * Apply `patch` and tell the listeners. A message given as a function is + * kept, so a locale change can word it again; a string stays as it is. + */ _setState(patch) { + for (const field of WORDED_FIELDS) { + if (!Object.hasOwn(patch, field)) continue; + const value = patch[field]; + this._wording[field] = typeof value === 'function' ? value : null; + if (this._wording[field]) patch = { ...patch, [field]: value() }; + } Object.assign(this.state, patch); const snapshot = this.getState(); for (const listener of this._listeners) { @@ -490,6 +532,18 @@ export class SdrController { } } + /** Word the kept messages again in the current locale, without telling. */ + _wordState() { + for (const field of WORDED_FIELDS) + if (this._wording[field]) this.state[field] = this._wording[field](); + } + + /** Word the kept messages again and tell the listeners. */ + _reword() { + this._wordState(); + this._setState({}); + } + _queueUsb(operation) { const epoch = this._usbEpoch; const gate = this._usbGate; @@ -1074,7 +1128,7 @@ export class SdrController { if (!this.state.webUsbSupported) { this._setState({ status: 'unsupported', - message: t('WebUSB requires desktop Chrome or Edge'), + message: () => t('WebUSB requires desktop Chrome or Edge'), }); return false; } @@ -1083,7 +1137,7 @@ export class SdrController { const superseded = () => this._destroyed || token !== this._sessionToken; this._setState({ status: 'connecting', - message: t('Waiting for an RTL-SDR device…'), + message: () => t('Waiting for an RTL-SDR device…'), errorText: null, }); let acquisition = null; @@ -1554,9 +1608,10 @@ export class SdrController { status: 'tuning', mode: nextMode, gain: this._gainByMode[nextMode], - message: t('Switching to {mode}…', { - mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', - }), + message: () => + t('Switching to {mode}…', { + mode: nextMode === 'adsb' ? 'ADS-B' : 'FM', + }), errorText: null, }); try { @@ -1675,9 +1730,10 @@ export class SdrController { ...(quiet ? {} : { - message: t('Tuned to {mhz} MHz FM', { - mhz: formatMhz(frequency), - }), + message: () => + t('Tuned to {mhz} MHz FM', { + mhz: formatMhz(frequency), + }), }), }); return true; @@ -1709,7 +1765,8 @@ export class SdrController { const token = ++this._seekToken; this._setState({ seeking: true, - seekMessage: seekDirection > 0 ? t('Seeking up…') : t('Seeking down…'), + seekMessage: () => + seekDirection > 0 ? t('Seeking up…') : t('Seeking down…'), }); this._audioNode?.port.postMessage({ type: 'volume', value: 0 }); try { @@ -1726,12 +1783,16 @@ export class SdrController { // tuneFm reports the failure; no spectrum is valid for this frame. if (!(await this.tuneFm(center, { quiet: true }))) { if (token === this._seekToken) - this._setState({ seeking: false, seekMessage: t('Seek stopped') }); + this._setState({ + seeking: false, + seekMessage: () => t('Seek stopped'), + }); return null; } const sequence = this._spectrumSequence; this._setState({ - seekMessage: t('Scanning {mhz} MHz…', { mhz: formatMhz(center) }), + seekMessage: () => + t('Scanning {mhz} MHz…', { mhz: formatMhz(center) }), }); if ( !(await this._waitForSpectrum(sequence)) || @@ -1752,13 +1813,15 @@ export class SdrController { await this.tuneFm(peak.frequency, { quiet: true }); this._setState({ seeking: false, - seekMessage: t('Found {mhz} MHz · {snr} dB', { - mhz: formatMhz(peak.frequency), - snr: formatOneDecimal(peak.snr), - }), - message: t('FM signal found at {mhz} MHz', { - mhz: formatMhz(peak.frequency), - }), + seekMessage: () => + t('Found {mhz} MHz · {snr} dB', { + mhz: formatMhz(peak.frequency), + snr: formatOneDecimal(peak.snr), + }), + message: () => + t('FM signal found at {mhz} MHz', { + mhz: formatMhz(peak.frequency), + }), }); return peak; } @@ -1766,7 +1829,7 @@ export class SdrController { await this.tuneFm(origin, { quiet: true }); this._setState({ seeking: false, - seekMessage: t('No FM signal found'), + seekMessage: () => t('No FM signal found'), }); } return null; @@ -1783,7 +1846,7 @@ export class SdrController { cancelSeek() { this._seekToken += 1; if (!this.state.seeking) return false; - this._setState({ seeking: false, seekMessage: t('Seek stopped') }); + this._setState({ seeking: false, seekMessage: () => t('Seek stopped') }); this._audioNode?.port.postMessage({ type: 'volume', value: this.state.volume, @@ -1803,7 +1866,7 @@ export class SdrController { if (typeof navigator === 'undefined' || !navigator.geolocation) { this._setState({ locationStatus: 'unavailable', - message: t('Browser location is unavailable'), + message: () => t('Browser location is unavailable'), }); return false; } @@ -1830,7 +1893,7 @@ export class SdrController { console.warn('[SDR] Receiver location failed:', error); this._setState({ locationStatus: 'denied', - message: t('Receiver location was not granted'), + message: () => t('Receiver location was not granted'), }); return false; } @@ -1873,7 +1936,10 @@ export class SdrController { status: preserveMessage ? this.state.status : idleStatus, ...(preserveMessage ? {} - : { message: message || t('RTL-SDR disconnected'), errorText: null }), + : { + message: message || (() => t('RTL-SDR disconnected')), + errorText: null, + }), snr: null, samplesPerSecond: 0, workerBlocks: 0, @@ -1893,6 +1959,8 @@ export class SdrController { this._audioNode = null; this.state.audioState = 'closed'; this._listeners.clear(); + this._offLocale?.(); + this._offLocale = null; } } diff --git a/src/sdr/sdrLocaleSwitch.test.mjs b/src/sdr/sdrLocaleSwitch.test.mjs new file mode 100644 index 000000000..f499f0e6d --- /dev/null +++ b/src/sdr/sdrLocaleSwitch.test.mjs @@ -0,0 +1,48 @@ +// The receiver's messages follow a language switch while anyone listens, +// reach a later listener in the current language, and stop following once +// the controller is destroyed. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t } from '../i18n/index.js'; +import { SdrController } from './controller.js'; + +const UNAVAILABLE = 'WebUSB is unavailable in this browser'; + +test('a listener hears the message worded again after a switch', () => { + const c = new SdrController({ storage: null, webUsb: null }); + const seen = []; + const off = c.subscribe((state) => seen.push(state.message)); + try { + setLocale('es'); + assert.equal(seen.at(-1), t(UNAVAILABLE)); + assert.notEqual(seen.at(-1), UNAVAILABLE); + } finally { + off(); + setLocale('en'); + } +}); + +test('a listener that subscribes after a switch gets the current language', () => { + const c = new SdrController({ storage: null, webUsb: null }); + c.subscribe(() => {})(); + setLocale('ru'); + try { + let first; + const off = c.subscribe((state) => (first ??= state.message)); + off(); + assert.equal(first, t(UNAVAILABLE)); + } finally { + setLocale('en'); + } +}); + +test('a destroyed controller no longer follows the language', async () => { + const c = new SdrController({ storage: null, webUsb: null }); + c.subscribe(() => {}); + await c.destroy(); + let reworded = 0; + c._reword = () => reworded++; + setLocale('es'); + setLocale('en'); + assert.equal(reworded, 0); +}); diff --git a/src/sharelink.lang.test.mjs b/src/sharelink.lang.test.mjs index fee4241f1..4cb48ddc7 100644 --- a/src/sharelink.lang.test.mjs +++ b/src/sharelink.lang.test.mjs @@ -4,7 +4,7 @@ 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'; +import { persistLocale } from './i18n/index.js'; function makeManager(href) { globalThis.window = { location: { href, hash: new URL(href).hash } }; @@ -53,10 +53,6 @@ test('with storage blocked the language picker keeps the choice in ?lang', () => get href() { return href; }, - assign(next) { - href = next; - }, - reload() {}, }; const history = { replaceState: (_s, _t, url) => { @@ -68,7 +64,7 @@ test('with storage blocked the language picker keeps the choice in ?lang', () => throw new Error('blocked'); }, }; - persistLocaleAndReload('ru', { location, history, storage: blocked }); + persistLocale('ru', { location, history, storage: blocked }); // Storage blocked: the choice survives only in ?lang. assert.equal(new URL(href).searchParams.get('lang'), 'ru'); }); diff --git a/src/standalone/application.js b/src/standalone/application.js index db7b21b38..fe152fd9b 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -7,7 +7,12 @@ import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js'; -import { applyDocumentTranslations, setLocale } from '../i18n/index.js'; +import { + applyDocumentTranslations, + getLocale, + onLocaleChange, + setLocale, +} from '../i18n/index.js'; import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; @@ -31,7 +36,7 @@ export function createStandaloneApplication({ // and every catalog lookup agree from the first painted frame. Module // scripts execute after parsing, so the DOM is ready here: the views // translate as they render, and the page's static data-i18n* markup (the - // loading screen) is translated once. + // loading screen) is translated here and after every language switch. const activeLocale = resolveLocale(); setLocale(activeLocale); applyDocumentLanguage(document, activeLocale); @@ -44,6 +49,13 @@ export function createStandaloneApplication({ let catalog; return createApplication({ createScene: async (context) => { + // A language switch applies in place; the page's own markup follows. + context.defer( + onLocaleChange(() => { + applyDocumentLanguage(document, getLocale()); + applyDocumentTranslations(document); + }), + ); placeSearch = createStandalonePlaceSearch({ // The bundled city and landmark data the offline name provider reads. // The search package takes it as plain data rather than importing it, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 5a461f98d..c3ad27c14 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -42,11 +42,10 @@ import { import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; import { - describeUnsavedWork, getLocale, - msg, - persistLocaleAndReload, - registerUnsavedWork, + onLocaleChange, + persistLocale, + setLocale, t, } from '../i18n/index.js'; import { applyLocaleChoice } from './localePicker.js'; @@ -721,63 +720,50 @@ export class StyleManager extends ShellFacade { /** * Publishes the language picker, which ProviderSettingsView renders. A - * choice reloads the page; _confirmLocaleReload asks first when that would - * lose work. + * choice switches the page's language in place and is kept for the next + * visit; the Display panel's label column is sized again for it. * @returns {void} */ _initLocaleSelector() { - // 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, - ), - ]; + this._lifetime.own( + onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())), + ); // A host that does not initialize Core's locale shows no picker. if (this._localePickerEnabled === false) return; + this._publishLocalePicker(); + } + + /** + * @param {{focus?: boolean}} [options] Focus the picker's button, as after + * a choice: the picker is drawn again for the new language. + */ + _publishLocalePicker({ focus = false } = {}) { useLocaleStore(applicationPinia()).publishPicker({ current: getLocale(), onSelect: (locale) => this._chooseLocale(locale), + focus, }); } /** * Switch to `locale`: nothing for the active language, otherwise store it - * and reload once _confirmLocaleReload agrees. + * and apply it to the page. * @param {string} locale - * @returns {'unchanged'|'declined'|'switched'} + * @returns {'unchanged'|'switched'} */ _chooseLocale(locale) { return applyLocaleChoice(locale, { current: getLocale(), - confirmSwitch: () => this._confirmLocaleReload(), - switchLocale: (next) => persistLocaleAndReload(next), + switchLocale: (next) => { + setLocale(persistLocale(next)); + // Focus once, as the redrawn picker mounts; a later remount leaves + // focus where it is. + this._publishLocalePicker({ focus: true }); + void nextTick(() => this._publishLocalePicker()); + }, }); } - /** - * 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(', ') }, - ), - ); - } - /** * Sizes one Display-panel label column to the longest label in the active * language, so slider tracks line up. Counts characters (labels are @@ -1801,8 +1787,6 @@ export class StyleManager extends ShellFacade { this._shareState.destroy(); this._locationNavigation.destroy(); this._lifetime.destroy(); - for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) - unregister(); useLocaleStore(applicationPinia()).publishPicker(null); this._recording.destroy(); this._mobileShell?.destroy(); diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index ab2999cf3..bf0242fba 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -22,6 +22,7 @@ import { _initCctvPanel } from './cctvBindings.js'; import { useCctvStore } from './stores/cctv.js'; import { useStatusStore } from './stores/status.js'; import { applicationPinia } from './vueRuntime.js'; +import { onLocaleChange } from '../i18n/index.js'; /** * Own camera-panel interaction and presentation; receive the camera port and @@ -59,6 +60,14 @@ export class CctvControls { this._calibrationEdit = null; this._actionGeneration = 0; this._initCctvPanel(); + // The panel and the grid chip hold translated text; show it again in a + // new locale, from a fresh layer state when there is one (its summary is + // worded by the layer). + this._offLocale = onLocaleChange(() => { + const state = this.cctv?.getUIState?.() ?? this._cctvState; + if (state) this._renderCctvState(state); + if (this._cctvChip) this._publishCctvChip({}); + }); if (this._cctvVideo && typeof MutationObserver !== 'undefined') { this._videoVisibilityObserver = new MutationObserver(() => this._renderCctvState(this._cctvState), @@ -136,6 +145,7 @@ export class CctvControls { destroy() { if (this.destroyed) return; this.destroyed = true; + this._offLocale(); this._cctvVideoSurface?.stop(); this._videoVisibilityObserver?.disconnect(); this._actionGeneration++; diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index ed5da4057..bba546d67 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -1,4 +1,4 @@ -import { t, tn } from '../i18n/index.js'; +import { getLocale, msg, t, tn } from '../i18n/index.js'; import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { createCctvVideoSurface } from './cctvVideo.js'; @@ -184,11 +184,18 @@ export function _renderCctvState(state) { const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0; let picker = this._picker; // The index keeps its identity until the view or the active camera - // changes, so an unchanged list keeps its options. - if (!picking && (picker?.source !== cameras || picker.outside !== outside)) + // changes, so an unchanged list keeps its options; a new locale words + // the closing line again. + if ( + !picking && + (picker?.source !== cameras || + picker.outside !== outside || + picker.locale !== getLocale()) + ) picker = { source: cameras, outside, + locale: getLocale(), options: cameras.map((camera) => pickerOption(camera)), note: outside ? outsideNoteText(outside) : '', }; @@ -305,19 +312,23 @@ export function _typeCctvSummary(text) { export function _publishCctvChip(patch) { const current = this._cctvChip ?? { visible: false, - label: t('loading frames'), + labelKey: msg('loading frames'), immediate: false, progress: '0/0', stopped: false, }; + // The chip keeps its English key, so a new locale shows it again; the + // view gets the label alone. const next = { ...current, ...patch }; + next.label = t(next.labelKey); if ( this._cctvChip && Object.keys(next).every((key) => next[key] === current[key]) ) return; this._cctvChip = next; - this.statusStore.publishCctv(next); + const { labelKey, ...chip } = next; + this.statusStore.publishCctv(chip); } export function _updateCctvSyncChip(loading, enabled) { @@ -334,7 +345,7 @@ export function _updateCctvSyncChip(loading, enabled) { // during a grid load, and flapping it would read as a slot machine. this._publishCctvChip({ visible: true, - label: t('loading frames'), + labelKey: msg('loading frames'), progress: `${loaded}/${total}`, }); return; @@ -345,7 +356,7 @@ export function _updateCctvSyncChip(loading, enabled) { this._cctvChipWasBusy = false; this._publishCctvChip({ visible: true, - label: t('camera grid ready'), + labelKey: msg('camera grid ready'), progress: `${total}/${total}`, }); clearTimeout(this._cctvChipHideTimer); diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index 5f95a3d96..a5c6d8c5a 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -8,7 +8,11 @@ import { formatCockpitCoordinate, formatCockpitWindDirection, } from './cockpitPresentation.js'; -import { COCKPIT_BRIEF_PAGES, CYCLE_HELP_KEYS } from './cockpitBriefPages.js'; +import { + COCKPIT_BRIEF_PAGES, + CYCLE_HELP_KEYS, + createBriefState, +} from './cockpitBriefPages.js'; // regionalModel.weatherCodeLabel is shared with node code and returns // English; translate it here. @@ -58,15 +62,41 @@ export function showBriefPage(index, { manual = false } = {}) { this.scheduleContextLayout(); } +// The state is the accessible name; the help is the tooltip. +function cycleWords(enabled) { + return { + autoRotate: enabled, + autoLabel: enabled ? t('CYCLE ON') : t('CYCLE OFF'), + autoHelp: t(CYCLE_HELP_KEYS[enabled ? 'on' : 'off']), + }; +} + +/** + * Word the briefing again in the current locale: its page, cycle toggle and + * whatever the regional card shows. + */ +export function relocalizeBrief() { + if (this.destroyed) return; + if (this.brief.news.state === null) { + const { news, local } = createBriefState(); + this.brief.news = news; + this.brief.local = { ...local, coordinates: this.brief.local.coordinates }; + } + Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled)); + this.showBriefPage(this.briefPageIndex); + if (this.regionalBrief) + this.renderRegionalBrief(this.regionalBrief, this.lastAircraftInfo); + else if (this.brief.news.state) + this.renderRegionalBriefStatus( + this.brief.news.state, + this.lastAircraftInfo, + ); +} + export function setBriefAutoRotate(enabled) { if (this.destroyed) return; this.briefAutoRotateEnabled = Boolean(enabled); - // The state is the accessible name; the help is the tooltip. - Object.assign(this.brief, { - autoRotate: this.briefAutoRotateEnabled, - autoLabel: this.briefAutoRotateEnabled ? t('CYCLE ON') : t('CYCLE OFF'), - autoHelp: t(CYCLE_HELP_KEYS[this.briefAutoRotateEnabled ? 'on' : 'off']), - }); + Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled)); this.publishBriefView(); if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); else this.stopBriefRotation(); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 4eb96c4ad..50a397fc8 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -32,7 +32,9 @@ import { createBriefState } from './cockpitBriefPages.js'; import { createInstrumentState, createRouteState, + createVisionState, } from './cockpitInstrumentState.js'; +import { onLocaleChange } from '../i18n/index.js'; import { publishBriefView, showBriefPage, @@ -43,6 +45,7 @@ import { maybeRefreshRegionalBrief, renderRegionalBriefStatus, renderRegionalBrief, + relocalizeBrief, } from './cockpitBriefing.js'; import { handleSignalClick, @@ -189,7 +192,7 @@ export class CockpitViewController { this.scratchAnchorCartographic = new Cesium.Cartographic(); this.scratchCameraCartographic = new Cesium.Cartographic(); this.scratchTargetCartographic = new Cesium.Cartographic(); - this._listenerRemovers = []; + this._listenerRemovers = [onLocaleChange(() => this.relocalize())]; // Camera mutations belong before scene update/culling. Changing the camera // from preRender makes 3D Tiles discover a new view after traversal and can @@ -356,6 +359,9 @@ export class CockpitViewController { renderRegionalBriefStatus(status, info) { return renderRegionalBriefStatus.call(this, status, info); } + relocalizeBrief() { + return relocalizeBrief.call(this); + } renderRegionalBrief(payload, info) { return renderRegionalBrief.call(this, payload, info); } @@ -391,6 +397,36 @@ export class CockpitViewController { return handleSignalClick.call(this, event); } + /** + * Word every Cockpit surface again in the current locale. In flight the + * last reading is shown again; otherwise the surfaces go back to their + * placeholders, which the next entry replaces. + */ + relocalize() { + if (this.destroyed) return; + this.relocalizeBrief(); + this.publishVision(createVisionState(this.visionMode)); + this.routeSignature = null; + if (this.active && this.lastAircraftInfo) { + this.rewordingSignals = true; + try { + this.updateHud(this.lastAircraftInfo, performance.now(), true); + } finally { + this.rewordingSignals = false; + } + return; + } + this.instruments = createInstrumentState(); + this.publishInstrumentsView(); + this.routeView = createRouteState(); + this.publishRouteView(); + this.contextView = { + ...createContextState(), + weather: this.contextView.weather, + }; + this.publishContextView(); + } + /** Revoke new work before the shell awaits layer restoration. */ stop() { if (this.destroyed) return; diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 78354a699..2319a6d21 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -41,6 +41,15 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) { const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`; if (this.signalSignatures.get(key) === signature) return; this.signalSignatures.set(key, signature); + // A locale change words the signal again where it stands. + const shown = this.rewordingSignals + ? this.signalItems.find((item) => item.key === key) + : null; + if (shown) { + Object.assign(shown, { title, detail }); + this.publishCockpitSignals(); + return; + } this.signalItems.unshift({ key, tone, diff --git a/src/ui/geoFileImport.js b/src/ui/geoFileImport.js index 5358e6cf2..d39ea7a80 100644 --- a/src/ui/geoFileImport.js +++ b/src/ui/geoFileImport.js @@ -4,14 +4,7 @@ import { MAX_IMPORTED_FILES, loadGeoFile, } from '../data/geoFileImport.js'; -import { - formatNumber, - msg, - registerUnsavedWork, - t, - tn, - translateKnown, -} from '../i18n/index.js'; +import { formatNumber, t, tn, translateKnown } from '../i18n/index.js'; import { flyToDataSource } from './flyToDataSource.js'; // One color per file, in import order, from the whiteboard palette. @@ -55,11 +48,6 @@ export function initGeoFileImport({ let hint = ''; 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; @@ -395,7 +383,6 @@ export function initGeoFileImport({ destroy() { if (destroyed) return; destroyed = true; - unregisterUnsavedWork(); disconnect(); lifetime.abort(); removeMapSource?.(); diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index 693fb02de..c503a75dd 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -9,7 +9,6 @@ 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() { @@ -149,19 +148,6 @@ const ROW_ACTIONS = { }; const press = (t, row, name) => t.act(ROW_ACTIONS[name], row.id); -// Runs first: tools left by later tests 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/i18n.js b/src/ui/i18n.js index f39752970..5fbf1816c 100644 --- a/src/ui/i18n.js +++ b/src/ui/i18n.js @@ -1,5 +1,11 @@ +import { watch } from 'vue'; import { createI18n } from 'vue-i18n'; -import { bindLocale, getCatalog, getLocale } from '../i18n/index.js'; +import { + bindLocale, + getCatalog, + getLocale, + notifyLocaleChange, +} from '../i18n/index.js'; import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js'; let pageI18n = null; @@ -42,5 +48,12 @@ export function applicationI18n() { return installed; }; bindLocale(pageI18n.global.locale); + // Code that translated text itself shows it again, whichever way the + // locale changed (setLocale or the ref). + watch( + () => getLocale(), + () => notifyLocaleChange(), + { flush: 'sync' }, + ); return pageI18n; } diff --git a/src/ui/imagerySplit.js b/src/ui/imagerySplit.js index 16a48bc9d..ee19a3f6f 100644 --- a/src/ui/imagerySplit.js +++ b/src/ui/imagerySplit.js @@ -1,3 +1,4 @@ +import { onLocaleChange } from '../i18n/index.js'; import { useImagerySplitStore } from './stores/imagerySplit.js'; import { applicationPinia } from './vueRuntime.js'; @@ -16,6 +17,9 @@ function defaultViewportWidth() { ); } +// A title or label given as a function is worded when published. +const worded = (text) => (typeof text === 'function' ? text() : text); + function defaultValueText(beforePercent, afterPercent) { return `A ${beforePercent} percent, B ${afterPercent} percent`; } @@ -30,7 +34,9 @@ function defaultValueText(beforePercent, afterPercent) { * `onChange` reports pointer and keyboard changes so the owner can mirror * them, and `requestRender` fires whenever the clamped value actually * changes. `destroy` is idempotent: it withdraws the line, clears the CSS - * property and restores the scene split captured at creation once. + * property and restores the scene split captured at creation once. The + * titles and label may be functions, which are called again after a locale + * change, like `formatValueText`. */ export function createImagerySplit({ scene, @@ -72,9 +78,9 @@ export function createImagerySplit({ handleClass: handleClass ?? null, beforeLabel, afterLabel, - beforeTitle: beforeTitle ?? null, - afterTitle: afterTitle ?? null, - ariaLabel: ariaLabel ?? null, + beforeTitle: worded(beforeTitle) ?? null, + afterTitle: worded(afterTitle) ?? null, + ariaLabel: worded(ariaLabel) ?? null, valueNow: beforePercent, valueText: formatValueText(beforePercent, 100 - beforePercent), }, @@ -144,6 +150,7 @@ export function createImagerySplit({ }; apply(initialValue, { render: false }); + const offLocale = onLocaleChange(publish); return { getValue() { @@ -166,6 +173,7 @@ export function createImagerySplit({ pointerId = null; captor = null; destroyed = true; + offLocale(); store.withdraw(key); if (cssProperty) cssTarget.style.removeProperty(cssProperty); if (scene && restoreValue != null) scene.splitPosition = restoreValue; diff --git a/src/ui/kit/UiPanelToggle.vue b/src/ui/kit/UiPanelToggle.vue index 58636154c..a196c7695 100644 --- a/src/ui/kit/UiPanelToggle.vue +++ b/src/ui/kit/UiPanelToggle.vue @@ -9,8 +9,8 @@ import UiButton from './UiButton.vue'; const props = defineProps({ /** The panel's id, which PanelChrome finds the button by. */ target: { type: String, required: true }, - /** The panel's name in "Expand {name}", already translated. */ - name: { type: String, default: () => t('panel') }, + /** The panel's name in "Expand {name}", already translated; "panel" when unset. */ + name: { type: String, default: null }, collapsed: { type: Boolean, required: true }, /** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */ sideways: { type: Boolean, default: false }, @@ -29,7 +29,7 @@ const label = computed(() => { ? props.words.expandLabel : props.words.collapseLabel; const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}'); - return t(key, { name: props.name }); + return t(key, { name: props.name ?? t('panel') }); }); const title = computed(() => { if (props.words) diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index af90b07db..5698b2115 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -16,6 +16,7 @@ import { formatNumber, getLocale, msg, + onLocaleChange, t, translateKnown, } from '../i18n/index.js'; @@ -369,6 +370,13 @@ export class LayerPanel { info: false, }); } + // The rows carry text this panel translates; show it in a new locale. + this._removers.push( + onLocaleChange(() => { + this._paint(); + this._refreshWeatherPanel(); + }), + ); this._paint(); this._refreshWeatherPanel(); } diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js index 25f3666c9..7bd2a61c7 100644 --- a/src/ui/localSdrControls.js +++ b/src/ui/localSdrControls.js @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { onLocaleChange, t } from '../i18n/index.js'; import { internetRadioAudioActive, localSdrCardView, @@ -62,6 +62,7 @@ export class LocalSdrControls { feeds?.subscribe?.(() => this._scheduleRender()) || null; // One request, no polling: learn whether the server has decoder feeds. void feeds?.probe?.()?.catch?.(() => {}); + this._offLocale = onLocaleChange(() => this._scheduleRender()); this._render(); } @@ -200,6 +201,7 @@ export class LocalSdrControls { this._receiverUnsubscribe?.(); this._radioUnsubscribe?.(); this._feedsUnsubscribe?.(); + this._offLocale?.(); this._feedsUnsubscribe = null; this._receiverUnsubscribe = null; this._radioUnsubscribe = null; diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index b10b9917e..bec9c4d39 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -180,18 +180,14 @@ export function typeaheadIndex(options, buffer, fromIndex) { } /** - * What choosing a language does. The current one changes nothing; another one - * switches after `confirmSwitch()` agrees (it asks when a reload loses work). + * What choosing a language does: the current one changes nothing, another + * one switches. * @param {string} locale - * @param {{current: string, confirmSwitch: () => boolean, switchLocale: (locale: string) => void}} actions - * @returns {'unchanged'|'declined'|'switched'} + * @param {{current: string, switchLocale: (locale: string) => void}} actions + * @returns {'unchanged'|'switched'} */ -export function applyLocaleChoice( - locale, - { current, confirmSwitch, switchLocale }, -) { +export function applyLocaleChoice(locale, { current, switchLocale }) { if (locale === current) return 'unchanged'; - if (!confirmSwitch()) return 'declined'; switchLocale(locale); return 'switched'; } diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index d5c6954f9..c00a0a32f 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -194,22 +194,16 @@ test('the search field appears only above the threshold', () => { ); }); -test('choosing the current language changes nothing; another asks, then switches', () => { +test('choosing the current language changes nothing; another switches', () => { const calls = []; - const actions = (answer) => ({ + const actions = { current: 'ru', - confirmSwitch: () => { - calls.push('confirm'); - return answer; - }, switchLocale: (locale) => calls.push(`switch:${locale}`), - }); - assert.equal(applyLocaleChoice('ru', actions(true)), 'unchanged'); - assert.deepEqual(calls, [], 'no prompt, no reload'); - assert.equal(applyLocaleChoice('es', actions(false)), 'declined'); - assert.deepEqual(calls, ['confirm']); - assert.equal(applyLocaleChoice('es', actions(true)), 'switched'); - assert.deepEqual(calls, ['confirm', 'confirm', 'switch:es']); + }; + assert.equal(applyLocaleChoice('ru', actions), 'unchanged'); + assert.deepEqual(calls, []); + assert.equal(applyLocaleChoice('es', actions), 'switched'); + assert.deepEqual(calls, ['switch:es']); }); test('navigation keys move without wrapping', () => { diff --git a/src/ui/localeSwitch.test.mjs b/src/ui/localeSwitch.test.mjs new file mode 100644 index 000000000..dc71c83ff --- /dev/null +++ b/src/ui/localeSwitch.test.mjs @@ -0,0 +1,130 @@ +// The language picker switches the page's language in place: the choice is +// stored for the next visit, ?lang leaves the address, onLocaleChange owners +// word their text again and the picker is drawn for the new language. +// Choosing the language already active does nothing. +import { test } from 'node:test'; +import { nextTick } from 'vue'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { UiLifetime } from './uiLifetime.js'; +import { useLocaleStore } from './stores/locale.js'; +import { applicationPinia } from './vueRuntime.js'; +import { getLocale, onLocaleChange, setLocale } from '../i18n/index.js'; + +/** Wire the shell's language choice. No picker markup here: the picker's own + * behaviour is covered by localePicker.test and localePickerView.test. */ +function renderSwitch() { + const previous = { + document: globalThis.document, + location: globalThis.location, + history: globalThis.history, + localStorage: globalThis.localStorage, + }; + const reloads = []; + const addresses = []; + const stored = []; + globalThis.document = { + getElementById: () => null, + querySelectorAll: () => [], + }; + globalThis.location = { + href: 'https://gev.example/?lang=ru#v=2', + assign: () => reloads.push('assign'), + reload: () => reloads.push('reload'), + }; + globalThis.history = { + replaceState: (_state, _title, href) => addresses.push(href), + }; + globalThis.localStorage = { + setItem: (key, value) => stored.push([key, value]), + getItem: () => null, + }; + const shell = { + services: {}, + _lifetime: new UiLifetime(), + _chooseLocale: StyleManager.prototype._chooseLocale, + _publishLocalePicker: StyleManager.prototype._publishLocalePicker, + _sizeDisplayLabelColumn() {}, + }; + StyleManager.prototype._initLocaleSelector.call(shell); + return { + choose: (locale) => shell._chooseLocale(locale), + reloads, + addresses, + stored, + restore() { + shell._lifetime.destroy(); + useLocaleStore(applicationPinia()).publishPicker(null); + Object.assign(globalThis, previous); + setLocale('en'); + }, + }; +} + +test('another language applies in place, is stored and leaves the address', async () => { + const view = renderSwitch(); + const heard = []; + const off = onLocaleChange(() => heard.push(getLocale())); + try { + assert.equal(view.choose('es'), 'switched'); + assert.equal(getLocale(), 'es'); + assert.deepEqual(heard, ['es'], 'owners word their text again'); + assert.deepEqual(view.reloads, [], 'no reload'); + assert.equal(view.stored.at(-1)[1], 'es'); + assert.deepEqual(view.addresses, ['https://gev.example/#v=2']); + const picker = useLocaleStore(applicationPinia()).picker; + assert.equal(picker.current, 'es'); + assert.equal(picker.focus, true, 'the redrawn picker keeps focus'); + await nextTick(); + assert.equal( + useLocaleStore(applicationPinia()).picker.focus, + false, + 'only as it mounts', + ); + } finally { + off(); + view.restore(); + } +}); + +test('the language already active changes nothing', () => { + const view = renderSwitch(); + try { + assert.equal(view.choose('en'), 'unchanged'); + assert.deepEqual(view.stored, []); + assert.deepEqual(view.addresses, []); + assert.deepEqual(view.reloads, []); + } finally { + view.restore(); + } +}); + +test('a host without Core locales shows no picker; otherwise the shell publishes one', () => { + const store = useLocaleStore(applicationPinia()); + store.publishPicker(null); + const disabled = { + services: {}, + _localePickerEnabled: false, + _lifetime: new UiLifetime(), + }; + const chosen = []; + const enabled = { + services: {}, + _lifetime: new UiLifetime(), + _publishLocalePicker: StyleManager.prototype._publishLocalePicker, + _chooseLocale: (locale) => chosen.push(locale), + }; + try { + StyleManager.prototype._initLocaleSelector.call(disabled); + assert.equal(store.picker, null); + StyleManager.prototype._initLocaleSelector.call(enabled); + assert.equal(store.picker.current, 'en'); + assert.equal(store.picker.focus, false); + store.picker.onSelect('fr'); + assert.deepEqual(chosen, ['fr']); + } finally { + store.publishPicker(null); + disabled._lifetime.destroy(); + enabled._lifetime.destroy(); + } +}); diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs deleted file mode 100644 index d5c766dc1..000000000 --- a/src/ui/localeSwitchReload.test.mjs +++ /dev/null @@ -1,164 +0,0 @@ -// The language picker reloads the page, so it asks first while the -// reload would lose work (unsaved CCTV calibration, the open cockpit, anything -// a module registers). Choosing the language already active does nothing. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { StyleManager } from './applicationShell.js'; -import { useLocaleStore } from './stores/locale.js'; -import { applicationPinia } from './vueRuntime.js'; -import { - describeUnsavedWork, - msg, - registerUnsavedWork, - setLocale, -} from '../i18n/index.js'; - -/** Wire the shell's language choice with the given shell state. No picker - * markup here: the picker's own behaviour is covered by localePicker.test - * and localePickerView.test. */ -function renderSwitch({ calDirty = false, cockpit = false } = {}) { - const previous = { - document: globalThis.document, - location: globalThis.location, - history: globalThis.history, - confirm: globalThis.confirm, - }; - const reloads = []; - const prompts = []; - let answer = true; - globalThis.document = { getElementById: () => null }; - 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, - _chooseLocale: StyleManager.prototype._chooseLocale, - }; - StyleManager.prototype._initLocaleSelector.call(shell); - return { - choose: (locale) => shell._chooseLocale(locale), - 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.choose('es'); - 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.choose('ru'); - 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.choose('ru'); - 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.choose('es'); - 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.choose('en'); - assert.equal(view.prompts.length, 1); - assert.doesNotMatch(view.prompts[0], /Switching language/); - } finally { - view.restore(); - setLocale('en'); - } -}); - -test('the language already active neither asks nor reloads', () => { - const view = renderSwitch({ calDirty: true }); - try { - assert.equal(view.choose('en'), 'unchanged'); - assert.deepEqual(view.prompts, []); - assert.equal(view.reloads.length, 0); - } finally { - view.restore(); - } -}); - -test('a host without Core locales shows no picker; otherwise the shell publishes one', () => { - const store = useLocaleStore(applicationPinia()); - store.publishPicker(null); - const disabled = { services: {}, _localePickerEnabled: false }; - const enabled = { - services: {}, - _chooseLocale: (locale) => chosen.push(locale), - }; - const chosen = []; - try { - StyleManager.prototype._initLocaleSelector.call(disabled); - assert.equal(store.picker, null); - StyleManager.prototype._initLocaleSelector.call(enabled); - assert.equal(store.picker.current, 'en'); - store.picker.onSelect('fr'); - assert.deepEqual(chosen, ['fr']); - } finally { - store.publishPicker(null); - for (const shell of [disabled, enabled]) - for (const unregister of shell._unsavedWorkChecks ?? []) unregister(); - } -}); diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 5b5f06d5e..6fd827788 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -1,5 +1,5 @@ import { locationMiniStatus } from '../locationStatus.js'; -import { t } from '../i18n/index.js'; +import { onLocaleChange, t } from '../i18n/index.js'; import { localizePlaceName } from './placeNames.js'; const POI_KEYS = ['Q', 'W', 'E', 'R', 'T']; @@ -55,6 +55,15 @@ export class LocationControls { this.removers = []; this.frame = null; this.destroyed = false; + // City names and the status lines are translated here; show them again + // in a new locale. + this.removers.push( + onLocaleChange(() => { + if (this._statusRecord !== undefined) + this.renderStatus(this._statusRecord); + else this.present(); + }), + ); this.rowGeneration = 0; this.activeCity = null; this.poiCity = null; @@ -182,6 +191,7 @@ export class LocationControls { } renderStatus(record) { if (this.destroyed) return; + this._statusRecord = record; // A preset city shows its name in the UI language; the record stays English. const state = record?.city?.name ? { diff --git a/src/ui/mapSourceControls.js b/src/ui/mapSourceControls.js index ed23fe412..1f7babe25 100644 --- a/src/ui/mapSourceControls.js +++ b/src/ui/mapSourceControls.js @@ -1,5 +1,5 @@ import { mapStackChipModels } from '../mapStackChips.js'; -import { t } from '../i18n/index.js'; +import { onLocaleChange, t } from '../i18n/index.js'; /** * Own Map Source presentation and selection without constructing map providers. @@ -77,6 +77,9 @@ export function createMapSourceControls({ render(controller.getState()); onStateChanged(); }); + // The chips' names and hints are translated; read them again in a new + // locale. + const offLocale = onLocaleChange(refresh); refresh(); return { render, @@ -87,6 +90,7 @@ export function createMapSourceControls({ destroyed = true; generation++; unsubscribe?.(); + offLocale(); }, }; } diff --git a/src/ui/radioBindings.js b/src/ui/radioBindings.js index 58664ed90..05a211537 100644 --- a/src/ui/radioBindings.js +++ b/src/ui/radioBindings.js @@ -134,6 +134,8 @@ export function bindRadioControls() { return true; }; this._refreshRadioTunerBand = refreshTunerBand; + this._refreshRadioTunerText = () => + tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false }); const beginTuner = () => { if (this._radioTunerDragging || !this._radioTunerView.available) return false; diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index 028e171ae..d893a2ab0 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -6,6 +6,7 @@ import { } from './radioPresentation.js'; import { useRadioStore } from './stores/radio.js'; import { applicationPinia } from './vueRuntime.js'; +import { onLocaleChange } from '../i18n/index.js'; /** * Own Radio interaction, the tuner and the subscription; receive playback and @@ -49,7 +50,16 @@ export class RadioControls { this._radioTunerSelectedId = null; this._radioTunerBandPinnedForNavigation = false; this._refreshRadioTunerBand = null; + this._refreshRadioTunerText = null; bindRadioControls.call(this); + // The panel carries translated text; present it again in a new locale, + // from a fresh layer state when there is one (its category labels are + // worded by the layer). + this._offLocale = onLocaleChange(() => { + const state = this.radio.getUIState?.() ?? this._radioState; + if (state) this._renderRadioState(state); + this._refreshRadioTunerText?.(); + }); } listen(target, type, handler, options = {}) { @@ -133,6 +143,7 @@ export class RadioControls { destroy() { if (this.destroyed) return; this.destroyed = true; + this._offLocale(); this.listeners.abort(); if (this._storeToken) this.store.detach(this._storeToken); this._radioUnsubscribe?.(); diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index ea9c2c253..252838a21 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -56,7 +56,10 @@ export function renderRadioState(state) { // The options change only with the categories; until they first do, the // markup's own option stands. const categorySignature = categories - .map((category) => `${category.id}:${category.count}:${category.color}`) + .map( + (category) => + `${category.id}:${category.label}:${category.count}:${category.color}`, + ) .join('|'); if (categorySignature !== (this._radioCategorySignature ?? '')) { this._radioCategorySignature = categorySignature; diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index dbf7e45e2..b79fc14a4 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -9,7 +9,14 @@ * rail-card blocks and cards. Source toggles live on the data-panel row. */ import { h, render as renderVue } from 'vue'; -import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js'; +import { + formatNumber, + msg, + onLocaleChange, + t, + tn, + translateKnown, +} from '../i18n/index.js'; import { createImagerySplit } from './imagerySplit.js'; import RecentImageryReadout from './recentImagery/RecentImageryReadout.vue'; import { @@ -936,6 +943,8 @@ export function createRecentImageryPanel({ render(); } }); + // The readout's text is translated here; show it again in a new locale. + const offLocale = onLocaleChange(render); render(); return { @@ -945,6 +954,7 @@ export function createRecentImageryPanel({ if (destroyed) return; destroyed = true; unsubscribe(); + offLocale(); layer.setToolHandler?.(null); split?.destroy(); split = null; diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 220d35d99..0d5237ae7 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, msg } from '../i18n/index.js'; +import { t, msg, onLocaleChange } from '../i18n/index.js'; import { sceneButtonsDisabled, scenePercent } from './scenePresentation.js'; import { useSceneStore } from './stores/scenes.js'; import { applicationPinia } from './vueRuntime.js'; @@ -59,6 +59,13 @@ export class SceneControls { this.run(this.actions.reviewImport ? 'reviewImport' : 'import', file), }); this.presentPanel({}); + // Scene and shot titles are shown translated; the select and the list + // show them again. + this.offLocale = onLocaleChange(() => { + this.presentPanel({ playLabel: t('PLAY', { context: 'scene' }) }); + this.renderSceneSelect(); + this.renderShotList(); + }); if (subscribe) this.unsubscribe = subscribe((notification) => this.present(notification), @@ -264,6 +271,7 @@ export class SceneControls { this.actionGeneration++; this.unsubscribe?.(); this.unsubscribe = null; + this.offLocale(); this.setPlaybackKeyboardEnabled(false); // The panel keeps its last status and buttons, without shots or the // runtime chip. diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 6de4f62a8..7193c3c3a 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -8,7 +8,7 @@ import { reduceLoadingFeedback, reduceTrafficSyncFeedback, } from '../loadingFeedback.js'; -import { getLocale, t, msg } from '../i18n/index.js'; +import { getLocale, msg, onLocaleChange, t } from '../i18n/index.js'; import { localizeLayerName } from './layerPanel.js'; import { translateFeedError } from './statusText.js'; import { useStatusStore } from './stores/status.js'; @@ -127,6 +127,11 @@ export class ShellFeedback { this.readLayers = readLayers; this.store = store; this.destroyed = false; + // The chips keep the English they were given, to show it again in a new + // locale. + this._loadingSource = { label: msg('LOADING LIVE DATA'), detail: '' }; + this._trafficLabel = msg('syncing road network'); + this._offLocale = onLocaleChange(() => this._relocalize()); this._loadingFeedbackState = createLoadingFeedbackState(); this._trafficSyncFeedbackState = createTrafficSyncFeedbackState(); this._loadingFeedbackEvent = null; @@ -184,6 +189,7 @@ export class ShellFeedback { // signal rather than another animated loading stage. The progress is // written on every change INCLUDING the empty settled value: the reducer // clears it once the sync lands. + if (presentation.label) this._trafficLabel = presentation.label; this._publishTraffic({ visible: presentation.visible, ...(presentation.label && { @@ -228,6 +234,7 @@ export class ShellFeedback { this._publishLoading({ hidden: true, state: null }); return; } + this._loadingSource = presentation; const localized = localizeLoadingPresentation(presentation); // The LABEL flaps ("LOADING LIVE DATA" -> "LOAD COMPLETE") only when it // changes, which matters here: this runs on every 60 ms and 500 ms tick. @@ -241,6 +248,19 @@ export class ShellFeedback { }); } + /** Show the chips' last text in the current locale. */ + _relocalize() { + if (this.destroyed) return; + if (this._loadingSource) { + const { label, detail } = localizeLoadingPresentation( + this._loadingSource, + ); + this._publishLoading({ label, detail }); + } + if (this._trafficLabel) + this._publishTraffic({ label: localizeStatusLabel(this._trafficLabel) }); + } + _publishLoading(patch) { const next = { ...this._loadingView, ...patch }; if (Object.keys(next).every((key) => next[key] === this._loadingView[key])) @@ -329,6 +349,7 @@ export class ShellFeedback { destroy() { if (this.destroyed) return; this.destroyed = true; + this._offLocale(); this._stopLoadingFeedbackTicker(); clearInterval(this._trafficChipTicker); this._trafficChipTicker = null; diff --git a/src/ui/stores/locale.js b/src/ui/stores/locale.js index 619e908bc..f07db76d7 100644 --- a/src/ui/stores/locale.js +++ b/src/ui/stores/locale.js @@ -7,10 +7,10 @@ import { markRaw, shallowRef } from 'vue'; * picker; a host that does not initialize Core's locale never does. */ export const useLocaleStore = defineStore('gev-locale', () => { - /** `{current, onSelect}`, or null while no picker is shown. */ + /** `{current, onSelect, focus}`, or null while no picker is shown. */ const picker = shallowRef(null); - /** @param {?{current: string, onSelect: (locale: string) => void}} next */ + /** @param {?{current: string, onSelect: (locale: string) => void, focus?: boolean}} next */ function publishPicker(next) { picker.value = next && markRaw(next); } diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 4c0efeb5d..d680bc00f 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -5,7 +5,7 @@ import { } from './streetLevelPresentation.js'; import { useStreetLevelStore } from './stores/streetLevel.js'; import { applicationPinia } from './vueRuntime.js'; -import { t, translateKnown } from '../i18n/index.js'; +import { onLocaleChange, t, translateKnown } from '../i18n/index.js'; const RENDER_MODE_KEY = 'gev:street-level:render-mode'; /** classes of the modes that hide every panel; an expanded photo leaves with them. */ @@ -55,6 +55,10 @@ export class StreetLevelControls { this._resizeQueued = false; this._elements = this._collect(); this._bind(); + // The presentation holds translated text; present it again in a new locale. + this._offLocale = onLocaleChange(() => { + if (this._state) this.render(this._state); + }); } _collect() { @@ -351,6 +355,7 @@ export class StreetLevelControls { destroy() { if (this.destroyed) return; this.destroyed = true; + this._offLocale(); this._exitExpanded(); // Its `toggle` is not heard once destroyed: a popover left on the wrap // would hide the viewer even inside the panel. diff --git a/src/ui/uiLifetime.js b/src/ui/uiLifetime.js index a9cb9c9ed..6dcd3c9b6 100644 --- a/src/ui/uiLifetime.js +++ b/src/ui/uiLifetime.js @@ -20,6 +20,15 @@ export class UiLifetime { this.removers.add(remove); return remove; } + /** Run `remove` at teardown, or now when already torn down. */ + own(remove) { + if (this.destroyed) { + remove(); + return remove; + } + this.removers.add(remove); + return remove; + } frame(callback) { if (this.destroyed) return null; const frame = requestAnimationFrame((time) => { diff --git a/src/ui/views/ProviderSettingsView.vue b/src/ui/views/ProviderSettingsView.vue index 7d63fdd6c..1ab8df581 100644 --- a/src/ui/views/ProviderSettingsView.vue +++ b/src/ui/views/ProviderSettingsView.vue @@ -61,7 +61,7 @@ const removeTitle = (title) => {{ chip ? chip.label : t('POWER UP') }} - +