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') }} - +