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