mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
ba20bc13d8
commit
b20c80e590
87 files changed
+1194
-777
No files matched your search
+26
-42
@@ -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();
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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?.();
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}', {
|
||||
language: props.nativeNames[props.current] || props.current,
|
||||
});
|
||||
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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user