Switch language in place, without a reload

Choosing a language in the picker now applies it at once. The choice is
stored and ?lang is kept in step through replaceState, and the picker keeps
focus.

Code that writes translated text itself re-presents it on a switch:
onLocaleChange() lets an owner listen, removed at teardown, and status
writers hand over a function of the locale (voice cards, scene director,
SDR, draw hints, imagery split, map sources, Bhote Koshi media) so the
words are rebuilt rather than re-sent. The HUD, cockpit, radio, CCTV,
launch, awareness and credit texts follow the same hook, and
applyDocumentTranslations can be applied again without undoing text code
wrote since.

The unsaved-work registry and persistLocaleAndReload existed only to guard
the reload, so they go, with their catalog entries.
This commit is contained in:
Sameh Khamis committed 2026-11-01 16:47:39 -08:00
1 parent ba20bc13d8
commit b20c80e590
87 files changed
+1153 -736

No files matched your search

+8
View File
@@ -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 `<html lang>`/`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
+4 -4
View File
@@ -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 `<html lang>`/`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
+11 -1
View File
@@ -47,7 +47,17 @@ Static page markup that no view renders (the loading screen in `index.html`, a h
<input placeholder="Search any location..." data-i18n-placeholder />
```
`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
+1 -1
View File
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.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
+21 -24
View File
@@ -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);
@@ -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 {
+2 -1
View File
@@ -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...')
+4 -4
View File
@@ -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,18 +14,18 @@ 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') {
// 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);
if (!expand.hasAttribute?.('aria-expanded'))
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
+13
View File
@@ -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:');
});
+75 -26
View File
@@ -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', {
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.', {
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(
word(title, 'textContent', () =>
bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
).toUpperCase(),
);
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = t('{provider} · EMBEDDED SOURCE', {
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}', {
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', {
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', {
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();
+24 -5
View File
@@ -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 = '';
+23 -1
View File
@@ -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();
}
}
+51 -26
View File
@@ -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', () => {
+63 -63
View File
@@ -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 <html lang>/<html dir> 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
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;
}
-7
View File
@@ -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",
-7
View File
@@ -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",
-7
View File
@@ -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",
-7
View File
@@ -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",
-7
View File
@@ -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": "フライトレイヤーが有効になっていません",
-7
View File
@@ -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",
-7
View File
@@ -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": "Выйдите из кабины, чтобы показать контакты в кадре",
-7
View File
@@ -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": "Шар рейсів не ввімкнено",
-7
View File
@@ -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": "军用航班图层未开启",
-7
View File
@@ -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": "軍用航班圖層未開啟",
+7 -8
View File
@@ -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') &&
-29
View File
@@ -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(), []);
});
+14 -1
View File
@@ -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 = '';
+9 -1
View File
@@ -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;
+20
View File
@@ -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,
+1 -5
View File
@@ -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)
+1
View File
@@ -38,6 +38,7 @@ export function createState({ services }) {
layerState._stationById = new Map();
layerState._categories = [];
layerState._categoriesLocale = null;
layerState._renderById = new Map();
+3 -1
View File
@@ -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`
);
}
+5 -2
View File
@@ -34,8 +34,10 @@ 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', {
? () =>
t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'),
})
: null,
@@ -44,7 +46,8 @@ export function createDefaultMapSources({
return {
...common,
available: Boolean(googleTileset),
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
unavailableReason: () =>
photorealUnavailableReason(hasIon || hasGoogle),
tileset: googleTileset,
};
const imagery =
+66 -36
View File
@@ -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,7 +363,8 @@ export class SceneDirector {
);
this._toastStorageError(
invalid
? t('Scene not saved — {reason}', {
? () =>
t('Scene not saved — {reason}', {
reason: sceneSaveFailureReason(e),
})
: undefined,
@@ -361,13 +373,16 @@ export class SceneDirector {
}
}
/** 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}', {
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
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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', {});
},
};
+3 -1
View File
@@ -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);
+2 -2
View File
@@ -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'));
+89 -21
View File
@@ -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,18 +308,27 @@ 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'
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,7 +1608,8 @@ export class SdrController {
status: 'tuning',
mode: nextMode,
gain: this._gainByMode[nextMode],
message: t('Switching to {mode}…', {
message: () =>
t('Switching to {mode}…', {
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
}),
errorText: null,
@@ -1675,7 +1730,8 @@ export class SdrController {
...(quiet
? {}
: {
message: t('Tuned to {mhz} MHz FM', {
message: () =>
t('Tuned to {mhz} MHz FM', {
mhz: formatMhz(frequency),
}),
}),
@@ -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,11 +1813,13 @@ export class SdrController {
await this.tuneFm(peak.frequency, { quiet: true });
this._setState({
seeking: false,
seekMessage: t('Found {mhz} MHz · {snr} dB', {
seekMessage: () =>
t('Found {mhz} MHz · {snr} dB', {
mhz: formatMhz(peak.frequency),
snr: formatOneDecimal(peak.snr),
}),
message: t('FM signal found at {mhz} MHz', {
message: () =>
t('FM signal found at {mhz} MHz', {
mhz: formatMhz(peak.frequency),
}),
});
@@ -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;
}
}
+48
View File
@@ -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);
});
+2 -6
View File
@@ -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');
});
+14 -2
View File
@@ -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,
+26 -42
View File
@@ -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();
+10
View File
@@ -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++;
+18 -7
View File
@@ -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);
+37 -7
View File
@@ -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();
+37 -1
View File
@@ -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;
+9
View File
@@ -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,
+1 -14
View File
@@ -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?.();
-14
View File
@@ -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);
+14 -1
View File
@@ -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;
}
+12 -4
View File
@@ -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;
+3 -3
View File
@@ -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)
+8
View File
@@ -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();
}
+3 -1
View File
@@ -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;
+5 -9
View File
@@ -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';
}
+7 -13
View File
@@ -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', () => {
+130
View File
@@ -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();
}
});
-164
View File
@@ -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();
}
});
+11 -1
View File
@@ -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
? {
+5 -1
View File
@@ -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();
},
};
}
+2
View File
@@ -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;
+11
View File
@@ -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?.();
+4 -1
View File
@@ -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;
+11 -1
View File
@@ -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;
+9 -1
View File
@@ -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.
+22 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
}
+6 -1
View File
@@ -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';
/** <body> 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.
+9
View File
@@ -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) => {
+1 -1
View File
@@ -61,7 +61,7 @@ const removeTitle = (title) =>
<span data-key-setup-chip-label>{{ chip ? chip.label : t('POWER UP') }}</span>
</button>
</Teleport>
<LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" />
<LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" :focus="picker.focus" />
</div>
<aside v-if="available" id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" :hidden="dialog.hidden" v-class-toggle="{ visible: dialog.visible }" :data-ready="view?.ready ? 'true' : undefined" @transitionend="controls?.transitionEnd()">
<div class="key-setup-scanline" aria-hidden="true"></div>
+6 -6
View File
@@ -39,20 +39,20 @@ const celestialTitle = computed(() =>
);
const models3dOn = computed(() => models3d.value?.pressed ?? true);
const modelsMode = computed(() => models3d.value?.mode ?? 'proximity');
const modelsOptions = [
const modelsOptions = computed(() => [
{ value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } },
{ value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } },
];
]);
const drawShape = computed(() => draw.value?.shape ?? 'area');
const drawShapeOptions = [
const drawShapeOptions = computed(() => [
{ value: 'area', label: t('Area'), attrs: { 'data-shape': 'area' } },
{ value: 'line', label: t('Line'), attrs: { 'data-shape': 'line' } },
{ value: 'pin', label: t('Pin'), attrs: { 'data-shape': 'pin' } },
];
const allocationOptions = [
]);
const allocationOptions = computed(() => [
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
];
]);
</script>
<template>
+11 -3
View File
@@ -3,6 +3,7 @@ import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
shallowRef,
useTemplateRef,
} from 'vue';
@@ -23,6 +24,8 @@ import { vClassToggle } from '../../rail/classToggle.js';
const props = defineProps({
current: { type: String, required: true },
onSelect: { type: Function, required: true },
/** Focus the button once drawn: the picker is drawn again after a choice. */
focus: { type: Boolean, default: false },
locales: { type: Array, default: () => LOCALES },
nativeNames: { type: Object, default: () => LOCALE_NAMES },
searchThreshold: { type: Number, default: LOCALE_SEARCH_THRESHOLD },
@@ -73,9 +76,11 @@ const focusTarget = () => search.value || listbox.value;
const activeDescendant = computed(() =>
view.value.active ? ids.option(view.value.active) : undefined,
);
const buttonLabel = t('Language: {language}', {
const buttonLabel = computed(() =>
t('Language: {language}', {
language: props.nativeNames[props.current] || props.current,
});
}),
);
let swallowSpaceKeyup = false;
@@ -88,7 +93,7 @@ function close({ restoreFocus = false } = {}) {
if (restoreFocus) button.value?.focus({ preventScroll: true });
}
// The shell may ask before switching; the list is closed on screen first.
// The list is closed on screen before the shell switches.
async function choose(code) {
close({ restoreFocus: true });
if (code === props.current) return;
@@ -173,6 +178,9 @@ function onFocusOut(event) {
if (model.open && next && !root.value?.contains(next)) close();
}
onMounted(() => {
if (props.focus) button.value?.focus({ preventScroll: true });
});
onBeforeUnmount(() => close());
defineExpose({ open, close, isOpen: () => model.open });
+2 -2
View File
@@ -27,12 +27,12 @@ const setFilter = (select) => {
// its own frames.
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
// The collapse button keeps Radio's own wording, not "Expand RADIO".
const radioWords = {
const radioWords = computed(() => ({
expandLabel: t('Expand Radio section'),
collapseLabel: t('Collapse Radio section'),
expandTitle: t('Expand Radio'),
collapseTitle: t('Collapse Radio'),
};
}));
</script>
<template>
+4 -4
View File
@@ -38,7 +38,7 @@ const shows = computed(
);
const pano = computed(() => view.value?.filter.pano ?? 'all');
const renderModeOptions = [
const renderModeOptions = computed(() => [
{
value: 'letterbox',
label: t('FIT'),
@@ -55,12 +55,12 @@ const renderModeOptions = [
title: t('Fill: fill the frame edge to edge, cropping the photo'),
},
},
];
const panoOptions = [
]);
const panoOptions = computed(() => [
{ value: 'all', label: t('ALL'), attrs: { 'data-sl-pano': 'all' } },
{ value: 'pano', label: '360°', attrs: { 'data-sl-pano': 'pano' } },
{ value: 'flat', label: t('FLAT'), attrs: { 'data-sl-pano': 'flat' } },
];
]);
// The image line and OPEN keep what they last showed while hidden.
const image = shallowRef({
+10 -1
View File
@@ -3,7 +3,7 @@ import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { useDisplayStore } from './stores/display.js';
import { applicationPinia } from './vueRuntime.js';
import { t } from '../i18n/index.js';
import { onLocaleChange, t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
import {
VisualEffects,
@@ -180,6 +180,15 @@ export class VisualSettings {
readDisplayPortalActive,
});
this._lifetime = new UiLifetime();
// The display, the active style and the style sliders carry translated
// text; publish them again in a new locale.
this._lifetime.own(
onLocaleChange(() => {
this._publishActiveStyle();
this._publishDisplay();
this._refreshParameters();
}),
);
// The active style's published parameter rows; null while it has none.
this._parameters = null;
this._parametersToken = store.connect({
+62 -15
View File
@@ -1,5 +1,5 @@
import { nextTick } from 'vue';
import { t } from '../i18n/index.js';
import { onLocaleChange, t } from '../i18n/index.js';
import { useVoiceStore } from '../ui/stores/voice.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -73,30 +73,32 @@ function initialState(panel) {
hidden: true,
options: [],
value: '',
title: t('Changing provider reconnects voice if it is on.'),
title: () => t('Changing provider reconnects voice if it is on.'),
},
tier: {
hidden: false,
text: t('STD'),
text: () => t('STD'),
pressed: false,
title: t('Voice model tier — applies next session'),
title: () => t('Voice model tier — applies next session'),
},
cost: {
hidden: false,
text: '~$0.00',
level: 'ok',
title: t('Estimated session cost'),
title: () => t('Estimated session cost'),
},
costWarning: { hidden: true, text: '', level: null, title: null },
button: {
label: t('Voice control — activate to toggle voice; hold Space to speak'),
label: () =>
t('Voice control — activate to toggle voice; hold Space to speak'),
pressed: null,
},
mic: { text: t('ON/OFF') },
status: { text: voiceStatusLabel('idle') },
detail: { text: t('VOICE STANDBY'), title: null },
mic: { text: () => t('ON/OFF') },
status: { text: () => voiceStatusLabel('idle') },
detail: { text: () => t('VOICE STANDBY'), title: null },
help: {
text: t('Hold Space to speak · tap Space to activate focused controls'),
text: () =>
t('Hold Space to speak · tap Space to activate focused controls'),
},
error: { text: '' },
voicePanel: {
@@ -107,14 +109,17 @@ function initialState(panel) {
settingsExpanded: false,
selected: 'preview',
},
previewEmpty: { hidden: false, text: t('Voice replies appear here.') },
previewEmpty: {
hidden: false,
text: () => t('Voice replies appear here.'),
},
preferences: {
previewChecked: true,
previewNote: '',
timeoutChoice: '5',
timeoutMinutes: '5',
timeoutInputHidden: false,
timeoutNote: t('These preferences stay in this browser.'),
timeoutNote: () => t('These preferences stay in this browser.'),
timeoutStatus: '',
// Bumped by every owner write of the fields, so the view restores a
// refused edit but leaves a draft alone across other renders.
@@ -164,7 +169,30 @@ export function createVoiceControl({
} = {}) {
const id = ++controlCount;
const listeners = new Map();
// The fields given as functions, by part: they are called again when the
// locale changes, so the control's words follow the language.
const wording = new Map();
const word = (part, patch) => {
let fields = wording.get(part);
let worded = patch;
for (const [field, value] of Object.entries(patch)) {
if (typeof value !== 'function') {
fields?.delete(field);
continue;
}
if (!fields) wording.set(part, (fields = new Map()));
fields.set(field, value);
if (worded === patch) worded = { ...patch };
worded[field] = value();
}
return worded;
};
let state = initialState(Boolean(panel));
for (const [part, value] of Object.entries(state))
if (part !== 'card' && value && typeof value === 'object')
state[part] = word(part, value);
// The default card's labels follow the locale until a card replaces it.
let defaultCard = true;
// Each bar's `--audio-level` and `--audio-opacity`, or null at rest.
let bars = null;
let removed = false;
@@ -191,15 +219,21 @@ export function createVoiceControl({
get state() {
return state;
},
/** Merge `patch` into one part of the control. */
/**
* Merge `patch` into one part of the control. A field given as a
* function is shown as what it returns, and called again when the
* locale changes; give translated words that way.
*/
update(part, patch) {
if (removed) return;
state = { ...state, [part]: { ...state[part], ...patch } };
state = { ...state, [part]: { ...state[part], ...word(part, patch) } };
publish();
},
/** Replace one part of the control. */
replace(part, value) {
if (removed) return;
wording.delete(part);
if (part === 'card') defaultCard = false;
state = { ...state, [part]: value };
publish();
},
@@ -207,7 +241,8 @@ export function createVoiceControl({
* Listen for an interaction the view reports: `button`, `tier`,
* `provider-change`, `settings-button`, `settings-close`, `tab`,
* `tab-keydown`, `panel-keydown`, `pointerenter`, `pointerleave`,
* `preview-change`, `timeout-select`, `timeout-input`, `card-dismiss`.
* `preview-change`, `timeout-select`, `timeout-input`, `card-dismiss`;
* and `locale` after a locale change, for an owner's own words.
* @returns {() => void} Removes the listener.
*/
on(name, listener) {
@@ -268,9 +303,21 @@ export function createVoiceControl({
store.publishBars(null);
}
removed = true;
offLocale();
listeners.clear();
},
};
const offLocale = onLocaleChange(() => {
if (removed) return;
for (const [part, fields] of wording) {
const patch = {};
for (const [field, value] of fields) patch[field] = value();
state = { ...state, [part]: { ...state[part], ...patch } };
}
if (defaultCard) state = { ...state, card: initialCard() };
publish();
ui.emit('locale');
});
store.connect(ui);
store.publishBars(null);
publish();
+14 -5
View File
@@ -68,7 +68,7 @@ export function createVoicePanelHost({
};
const renderActivity = () => {
ui.update('preferences', {
timeoutStatus:
timeoutStatus: () =>
nativeProvider === 'openai' || nativeProvider === 'gemini'
? ''
: t('Automatic stop is unavailable for this voice connection.'),
@@ -234,9 +234,14 @@ export function createVoicePanelHost({
card,
getPreviewEnabled: () => preferences.snapshot().previewEnabled,
getCockpitActive: () => readCockpitState()?.active === true,
// Getters, so the panel words them in the current locale.
copy: {
previewEmpty: t('Voice replies appear here.'),
previewDisabled: t('Preview is off.'),
get previewEmpty() {
return t('Voice replies appear here.');
},
get previewDisabled() {
return t('Preview is off.');
},
},
onPresentationIntent: remember,
});
@@ -245,8 +250,12 @@ export function createVoicePanelHost({
preferences,
copy: {
previewNote: '',
timeoutNote: t('These preferences stay in this browser.'),
notSaved: t('Your choice applies now, but could not be saved.'),
get timeoutNote() {
return t('These preferences stay in this browser.');
},
get notSaved() {
return t('Your choice applies now, but could not be saved.');
},
},
onChange: (state) => {
if (!owns()) return;
+2 -1
View File
@@ -59,9 +59,10 @@ export function createManualVoicePanel({
selected: view,
});
card?.setPresented?.(open && view === 'preview' && !suppressed());
const previewOn = enabled();
ui.update('previewEmpty', {
hidden: ui.state.card.visible,
text: enabled() ? copy.previewEmpty : copy.previewDisabled,
text: () => (previewOn ? copy.previewEmpty : copy.previewDisabled),
});
if (restoreFocus) void ui.focus(['settingsButton', 'button']);
};
+5 -2
View File
@@ -120,8 +120,11 @@ export function bindPreferenceControls({ ui, preferences, copy, onChange }) {
if (customSelected) custom = state.minutes;
ui.update('preferences', {
previewChecked: state.previewEnabled,
previewNote: `${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
timeoutNote: `${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
// Functions, so a locale change words the notes again.
previewNote: () =>
`${copy.previewNote}${state.previewPersisted === false ? ` ${copy.notSaved}` : ''}`,
timeoutNote: () =>
`${copy.timeoutNote}${state.timeoutPersisted === false ? ` ${copy.notSaved}` : ''}`,
timeoutChoice:
state.minutes === 0 ? '0' : customSelected ? 'custom' : value,
timeoutMinutes: String(custom),
-6
View File
@@ -10,7 +10,6 @@ import { createVoiceCommands as createCommands } from './commands.js';
import { createGevActionRunner } from './gevActions.js';
import { createPointerTracker } from './pointerContext.js';
import { createReferentRegistry } from './referents.js';
import { describeUnsavedWork, t as translate } from '../i18n/index.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
const PROVIDER_KEY = 'gev.voice.provider';
@@ -749,15 +748,11 @@ for (const emitsIdle of [false, true]) {
}
test('outer destruction disposes the runner once and removes each provider binding', async (t) => {
const priorUnsaved = describeUnsavedWork();
const f = compositionFixture(t);
await f.facade.start();
f.select('gemini');
await settle();
assert.equal(f.annotations.size, 1);
assert.ok(
describeUnsavedWork().includes(translate('the active voice session')),
);
const oldCardEvents = f.views[0].cardView.events.length;
for (const callback of f.annotations) callback({ id: 'fixture-outline' });
assert.equal(f.views[0].cardView.outlines.length, 0);
@@ -771,7 +766,6 @@ test('outer destruction disposes the runner once and removes each provider bindi
assert.equal(f.facade.signal.aborted, true);
assert.equal(f.facade.disposed, true);
assert.equal(f.annotations.size, 0);
assert.deepEqual(describeUnsavedWork(), priorUnsaved);
const finalEvents = f.events.length;
const starts = f.instances.map((instance) => instance.starts.length);
for (const [index, view] of f.views.entries()) {
+11 -8
View File
@@ -415,23 +415,26 @@ export class GevRealtimeController extends RealtimeFacade {
});
this.updateVoiceButtonLabel();
const shownStatus = STATUS_STATES.has(status) ? status : 'idle';
this.ui.update('status', { text: voiceStatusLabel(shownStatus) });
const resolvedDetail =
status === 'listening' && this.pushToTalkMode
? this.pushToTalkKeyHeld
this.ui.update('status', { text: () => voiceStatusLabel(shownStatus) });
// Words are given as functions, so a locale change words them again.
const holdToTalk = status === 'listening' && this.pushToTalkMode;
const held = this.pushToTalkKeyHeld;
const resolvedDetail = () =>
holdToTalk
? held
? t('Release Space to send')
: t('Hold Space to talk')
: detail;
const primaryDetail =
const primaryDetail = () =>
status === 'error'
? t('VOICE UNAVAILABLE')
: resolvedDetail ||
: resolvedDetail() ||
(status === 'idle' ? t('VOICE STANDBY') : t('VOICE ACTIVE'));
this.ui.update('detail', { text: primaryDetail, title: primaryDetail });
this.ui.update('error', {
text:
text: () =>
status === 'error'
? resolvedDetail || t('Voice session could not be started.')
? resolvedDetail() || t('Voice session could not be started.')
: '',
});
if (status === 'idle' || status === 'connecting' || status === 'error') {
+3 -5
View File
@@ -363,12 +363,10 @@ export class RealtimeInput {
updateVoiceButtonLabel() {
if (!this.ui) return;
// The mic button's caption; the first-run tip names it the same way.
this.ui.update('mic', { text: t('MIC') });
this.ui.update('mic', { text: () => t('MIC') });
const { pushToTalkMode, pushToTalkKeyHeld } = this;
this.ui.update('help', {
text: resolveVoiceControlHint(
this.pushToTalkMode,
this.pushToTalkKeyHeld,
),
text: () => resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld),
});
}
+10 -2
View File
@@ -22,10 +22,18 @@ export function createRealtimeSession({
sendMapEvent: (event) => controller.notifyMapEvent(event),
ignoreButtonClick: () => Boolean(controller.spaceKeyHeld),
bindControls() {
// Kept as the remover; a full stop releases it.
controller.tierHandler = controller.ui.on('tier', () =>
// Kept as the remover; a full stop releases it. The cost readout and
// tier toggle are worded again in a new locale.
const offTier = controller.ui.on('tier', () =>
controller.toggleVoiceTier(),
);
const offLocale = controller.ui.on('locale', () =>
controller.syncCostUi(),
);
controller.tierHandler = () => {
offTier();
offLocale();
};
controller.syncCostUi();
controller.bindPushToTalkShortcut();
},
-6
View File
@@ -9,7 +9,6 @@ import { createVoiceSession } from './session.js';
import { createRealtimeSession } from './realtimeSession.js';
import { realtimeSessionEvent } from './realtimeEvents.js';
import { createVoiceCommands } from './sessionCommands.js';
import { describeUnsavedWork } from '../i18n/index.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
function deferred() {
@@ -294,20 +293,15 @@ test('common button and annotation bindings work with an alternate adapter and c
assert.equal(ui.state.help.text, 'Activate to toggle voice');
assert.equal(ui.state.status.text, 'LISTENING');
assert.equal(ui.state.detail.text, 'Ready');
// Switching language (a reload) asks first during a session.
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
subscribed({ status: 'resolved' });
assert.deepEqual(events, [
{ type: 'map_annotation_outline', status: 'resolved' },
]);
click();
assert.equal(controls.state, 'idle');
assert.deepEqual(describeUnsavedWork(), []);
click();
await new Promise((done) => setTimeout(done, 0));
assert.deepEqual(describeUnsavedWork(), ['the active voice session']);
lifetime.abort();
assert.deepEqual(describeUnsavedWork(), [], 'disposal unregisters');
assert.equal(subscribed, null);
assert.equal(removed, 1);
click();
+8 -12
View File
@@ -3,7 +3,7 @@ import { createVoiceSession } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
import { VOICE_PROVIDERS } from './providerCapabilities.js';
import { msg, registerUnsavedWork, t } from '../i18n/index.js';
import { t } from '../i18n/index.js';
function createDefaultReticle(pointer) {
const element = pointer?.element;
@@ -74,7 +74,8 @@ export function createVoiceCommands({
})),
}),
value: provider,
title: [
title: () =>
[
t('Changing provider reconnects voice if it is on.'),
capabilities.advisoryLimit,
]
@@ -91,8 +92,8 @@ export function createVoiceCommands({
ui.update('tier', { hidden: !capabilities.costControls });
ui.update('cost', { hidden: !capabilities.costControls });
if (!capabilities.pushToTalk) {
ui.update('button', { label: t('Toggle voice control') });
ui.update('help', { text: t('Activate to toggle voice') });
ui.update('button', { label: () => t('Toggle voice control') });
ui.update('help', { text: () => t('Activate to toggle voice') });
}
// Retain the existing controller's inspection surface for browser tools.
const controls = adapter.controller || session;
@@ -105,17 +106,17 @@ export function createVoiceCommands({
...(event.state === 'error' && { errorDismissed: false }),
});
// States are machine values; only the label is translated.
ui.update('status', { text: voiceStatusLabel(event.state) });
ui.update('status', { text: () => voiceStatusLabel(event.state) });
// The portable session reports 'Voice off' in English: show the idle
// label instead. Other details are provider messages, shown as they come.
const idle = event.state === 'idle';
const detail = event.detail === 'Voice off' ? '' : event.detail;
ui.update('detail', {
text: detail || (idle ? t('Voice off') : t('Voice active')),
text: () => detail || (idle ? t('Voice off') : t('Voice active')),
});
ui.update('button', { pressed: session.isActive() });
ui.update('error', {
text:
text: () =>
event.state === 'error'
? event.detail || t('Voice could not be started.')
: '',
@@ -169,10 +170,6 @@ export function createVoiceCommands({
card?.outline(event);
session.sendMapEvent({ type: 'map_annotation_outline', ...event });
});
// A reload (the language switch) ends the conversation: ask first.
const unregisterUnsavedWork = registerUnsavedWork(() =>
session.isActive() ? msg('the active voice session') : null,
);
const buttonHandler = () => {
if (adapter.ignoreButtonClick?.()) return;
if (session.isActive()) {
@@ -188,7 +185,6 @@ export function createVoiceCommands({
cleaned = true;
offButton();
offProvider?.();
unregisterUnsavedWork();
annotationUnsubscribe?.();
updateStatus();
updateCard?.();
+5
View File
@@ -40,6 +40,10 @@ export class VoiceCardControls {
this.hideTimer = null;
this.destroyed = false;
if (!ui) return;
// The card's labels are worded again in a new locale.
this.offLocale = ui.on('locale', () => {
if (!this.destroyed) this.render();
});
this.offDismiss = ui.on('card-dismiss', () =>
this.handle({ type: 'dismiss' }),
);
@@ -142,5 +146,6 @@ export class VoiceCardControls {
this.destroyed = true;
this.cancelIdle();
this.offDismiss?.();
this.offLocale?.();
}
}