mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
@@ -37,7 +37,14 @@ import { ShellFeedback } from './shellFeedback.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js';
|
||||
import {
|
||||
describeUnsavedWork,
|
||||
getLocale,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
registerUnsavedWork,
|
||||
t,
|
||||
} from '../i18n/index.js';
|
||||
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
|
||||
|
||||
/**
|
||||
@@ -710,7 +717,8 @@ export class StyleManager extends ShellFacade {
|
||||
/**
|
||||
* Renders the command-dock locale switch: one button per shipped locale,
|
||||
* labelled with its code and named in its own language. A click stores the
|
||||
* choice and reloads, so nothing re-renders live.
|
||||
* choice and reloads, so nothing re-renders live; it asks first when the
|
||||
* reload would lose work (see _confirmLocaleReload).
|
||||
* @returns {void}
|
||||
*/
|
||||
_initLocaleSelector() {
|
||||
@@ -718,6 +726,20 @@ export class StyleManager extends ShellFacade {
|
||||
'#control-panel .dock-locale-switch',
|
||||
);
|
||||
if (!container) return;
|
||||
// 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,
|
||||
),
|
||||
];
|
||||
const active = getLocale();
|
||||
for (const locale of LOCALES) {
|
||||
const button = document.createElement('button');
|
||||
@@ -731,12 +753,29 @@ export class StyleManager extends ShellFacade {
|
||||
button.classList.toggle('active', isActive);
|
||||
button.setAttribute('aria-pressed', String(isActive));
|
||||
button.addEventListener('click', () => {
|
||||
if (!this._confirmLocaleReload()) return;
|
||||
persistLocaleAndReload(button.dataset.locale);
|
||||
});
|
||||
container.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(', ') },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every Display-panel row label one shared column, sized in
|
||||
* characters to the longest label of the active language, so the slider
|
||||
@@ -1718,6 +1757,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._shareState.destroy();
|
||||
this._locationNavigation.destroy();
|
||||
this._lifetime.destroy();
|
||||
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
|
||||
unregister();
|
||||
this._recording.destroy();
|
||||
this._panelChrome.destroy();
|
||||
this._feedback.destroy();
|
||||
|
||||
@@ -326,7 +326,7 @@ export function bindCameraOrientationControls({
|
||||
: false;
|
||||
});
|
||||
if (result) {
|
||||
showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view');
|
||||
showToast?.(result.tilted ? t('Tilted view') : t('Straight-down view'));
|
||||
// The action reports the state it just commanded, so the button can
|
||||
// follow it without a second pick.
|
||||
tilted = result.tilted;
|
||||
@@ -345,7 +345,7 @@ export function bindCameraOrientationControls({
|
||||
})
|
||||
);
|
||||
});
|
||||
if (result) showToast?.('North up');
|
||||
if (result) showToast?.(t('North up'));
|
||||
sync();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// The blocked-visibility toast speaks the UI language: the Space Missions
|
||||
// guard reason (English data from contextModePolicy.js) is re-composed with
|
||||
// the layer name localized, a known lifecycle sentence is looked up, and an
|
||||
// unknown provider message passes through.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
contextLayerEnableBlockReason,
|
||||
parseContextLayerBlockReason,
|
||||
} from '../contextModePolicy.js';
|
||||
import {
|
||||
_handleContextLayerChange,
|
||||
localizeVisibilityBlockReason,
|
||||
} from './contextLayerChanges.js';
|
||||
|
||||
function fakeShell() {
|
||||
const toasts = [];
|
||||
return {
|
||||
destroyed: false,
|
||||
_userFacingContextNotificationTokens: new Set(),
|
||||
_contextSessionSnapshot: null,
|
||||
_contextModeChanging: false,
|
||||
_contextMode: 'space-missions',
|
||||
_contextModeEntering: null,
|
||||
_syncContextModeButtons() {},
|
||||
showToast(message) {
|
||||
toasts.push(message);
|
||||
},
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
|
||||
const ENGLISH_GUARD =
|
||||
'Space Missions isolates replay data. Exit the mode to enable Live Flights.';
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('the guard reason is English data and parses back to its layer name', () => {
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: 'space-missions',
|
||||
change: { layerId: 'flights', enabled: true, origin: 'user' },
|
||||
layerName: 'Live Flights',
|
||||
});
|
||||
assert.equal(reason, ENGLISH_GUARD);
|
||||
assert.deepEqual(parseContextLayerBlockReason(reason), {
|
||||
layer: 'Live Flights',
|
||||
});
|
||||
assert.equal(parseContextLayerBlockReason('Data source unavailable'), null);
|
||||
assert.equal(
|
||||
parseContextLayerBlockReason(
|
||||
'Space Missions isolates replay data. Exit the mode to enable .',
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('en: the toast shows the reason unchanged', () => {
|
||||
setLocale('en');
|
||||
const shell = fakeShell();
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason: ENGLISH_GUARD,
|
||||
});
|
||||
assert.deepEqual(shell.toasts, [ENGLISH_GUARD]);
|
||||
assert.equal(
|
||||
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
|
||||
'Overpass returned HTTP 503',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the Space Missions block toast is localized with the layer name`, () => {
|
||||
setLocale(locale);
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: 'space-missions',
|
||||
change: { layerId: 'flights', enabled: true, origin: 'user' },
|
||||
layerName: 'Live Flights',
|
||||
});
|
||||
const shell = fakeShell();
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason,
|
||||
});
|
||||
assert.equal(shell.toasts.length, 1);
|
||||
assert.notEqual(shell.toasts[0], ENGLISH_GUARD, 'toast must be localized');
|
||||
assert.doesNotMatch(shell.toasts[0], /Space Missions isolates/);
|
||||
assert.doesNotMatch(shell.toasts[0], /Live Flights/);
|
||||
});
|
||||
|
||||
test(`${locale}: a known lifecycle reason is looked up in the catalog`, () => {
|
||||
setLocale(locale);
|
||||
const shell = fakeShell();
|
||||
shell._contextMode = null;
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'street-level',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason: 'Data source unavailable',
|
||||
});
|
||||
assert.equal(shell.toasts.length, 1);
|
||||
assert.notEqual(shell.toasts[0], 'Data source unavailable');
|
||||
});
|
||||
|
||||
test(`${locale}: an unknown provider message passes through verbatim`, () => {
|
||||
setLocale(locale);
|
||||
assert.equal(
|
||||
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
|
||||
'Overpass returned HTTP 503',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON,
|
||||
cockpitEntryAllowed,
|
||||
isExplicitUserIntentOrigin,
|
||||
parseContextLayerBlockReason,
|
||||
recordContextRestoreExplicitChange,
|
||||
recordContextSessionUserChange,
|
||||
shouldExitContextForLayerChange,
|
||||
spaceMissionEntryCancellationDisposition,
|
||||
} from '../contextModePolicy.js';
|
||||
import { localizeLayerName } from './layerNames.js';
|
||||
|
||||
/**
|
||||
* A blocked-visibility reason in the UI language. The lifecycle keeps reasons
|
||||
* English (they also reach voice and tool callers): the Space Missions guard
|
||||
* is re-composed from its template with the layer name localized, any other
|
||||
* known sentence ("Data source unavailable") is looked up as is, and an
|
||||
* unknown one — a live provider message — passes through.
|
||||
*/
|
||||
export function localizeVisibilityBlockReason(reason) {
|
||||
if (typeof reason !== 'string' || !reason) return reason;
|
||||
const blocked = parseContextLayerBlockReason(reason);
|
||||
if (blocked)
|
||||
return t(SPACE_MISSIONS_LAYER_BLOCK_REASON, {
|
||||
layer: localizeLayerName(blocked.layer),
|
||||
});
|
||||
return translateKnown(reason);
|
||||
}
|
||||
|
||||
export function _handleContextLayerChange(change) {
|
||||
if (this.destroyed) return;
|
||||
@@ -95,7 +115,7 @@ export function _handleContextLayerChange(change) {
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(
|
||||
change.reason ||
|
||||
localizeVisibilityBlockReason(change.reason) ||
|
||||
t('That layer is unavailable in the current Context mode'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// K-49: the CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the
|
||||
// i18n scanner (scripts/i18n.mjs) never reads CSS, so es/ru show it in English.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STYLES = path.join(HERE, 'styles');
|
||||
const LOCALES = path.join(HERE, '..', 'i18n', 'locales');
|
||||
// cyber.css carries decorative theme strings (LOC / GRID, SYS 06 // ...);
|
||||
// street-level.css ' · ESC' is a key cap. Neither is UI copy.
|
||||
const DECORATIVE = new Set(['cyber.css', 'street-level.css']);
|
||||
|
||||
test('K-49: no user-visible text in CSS content: outside decorative themes', () => {
|
||||
const hits = [];
|
||||
for (const file of readdirSync(STYLES)) {
|
||||
if (!file.endsWith('.css') || DECORATIVE.has(file)) continue;
|
||||
const css = readFileSync(path.join(STYLES, file), 'utf8');
|
||||
css.split('\n').forEach((line, i) => {
|
||||
if (/^\s*content:\s*['"][^'"]*[A-Za-z]{2,}/.test(line))
|
||||
hits.push(`${file}:${i + 1} ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
|
||||
test('K-49: the CCTV acquisition caption has es and ru translations', () => {
|
||||
for (const lang of ['es', 'ru']) {
|
||||
const dict = JSON.parse(readFileSync(path.join(LOCALES, `${lang}.json`), 'utf8'));
|
||||
assert.ok('ACQUIRING FRAME' in dict, `${lang}.json lacks "ACQUIRING FRAME"`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// K-47: Flights / Military feed failure texts reach the DATA LAYERS row in the
|
||||
// UI language; the feeds keep them in English.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LayerPanel } from './layerPanel.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { httpError, LiveSourceError } from '../sources/live/contract.js';
|
||||
|
||||
const panel = LayerPanel.prototype;
|
||||
|
||||
function flightsRow(error) {
|
||||
return panel._buildMetaText({
|
||||
id: 'flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
stale: true,
|
||||
error,
|
||||
status: 429,
|
||||
retryInSec: 45,
|
||||
source: 'OpenSky',
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cases = [
|
||||
['OpenSky 429 anonymous', 'OpenSky rate limited (anonymous)'],
|
||||
['OpenSky auth', 'OpenSky auth required'],
|
||||
['generic 429', httpError({ status: 429 }, 'Flights').message],
|
||||
['HTTP 503', httpError({ status: 503 }, 'Military aircraft').message],
|
||||
[
|
||||
'network error',
|
||||
new LiveSourceError('unavailable', 'Flights network error').message,
|
||||
],
|
||||
['stale snapshot', 'Source snapshot 7 min old'],
|
||||
['snapshot time unknown', 'Source snapshot time unavailable'],
|
||||
['non-LiveSourceError', 'Live data unavailable'],
|
||||
['malformed', 'Malformed OpenSky response'],
|
||||
['malformed rows', 'Malformed adsb.lol aircraft rows'],
|
||||
];
|
||||
|
||||
test('en: feed failure texts read as before', () => {
|
||||
setLocale('en');
|
||||
for (const [, message] of cases)
|
||||
assert.equal(
|
||||
flightsRow(message),
|
||||
`STALE · OpenSky · ${message} · retry 45s`,
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: feed failure texts are translated in the layer row`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const [name, message] of cases) {
|
||||
const row = flightsRow(message);
|
||||
assert.ok(
|
||||
!row.includes(message),
|
||||
`${locale} ${name}: row still contains English detail: ${row}`,
|
||||
);
|
||||
}
|
||||
assert.match(flightsRow('Source snapshot 7 min old'), /\b7\b/);
|
||||
assert.match(flightsRow('Flights HTTP 503'), /\b503\b/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
+14
-1
@@ -5,7 +5,14 @@ import {
|
||||
MAX_IMPORTED_FILES,
|
||||
loadGeoFile,
|
||||
} from '../data/geoFileImport.js';
|
||||
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
msg,
|
||||
registerUnsavedWork,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
import { flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
// One color per file, in import order, from the whiteboard palette.
|
||||
@@ -44,6 +51,11 @@ export function initGeoFileImport({
|
||||
let cameraIntent = 0;
|
||||
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;
|
||||
@@ -428,6 +440,7 @@ export function initGeoFileImport({
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
unregisterUnsavedWork();
|
||||
lifetime.abort();
|
||||
removeMapSource?.();
|
||||
clearTimeout(groundTimer);
|
||||
|
||||
@@ -8,6 +8,7 @@ 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() {
|
||||
@@ -211,6 +212,20 @@ const rows = (els) => els['geo-import-list'].children;
|
||||
const action = (row, name) =>
|
||||
row.children.find((c) => c.dataset.action === name);
|
||||
|
||||
// K-27. Before the other tests: the tools they leave behind 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);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
|
||||
@@ -150,7 +151,7 @@ export function initImageryBoxTool({
|
||||
if (!active || destroyed || anchor) return;
|
||||
const point = worldAt(event.position);
|
||||
if (!point) {
|
||||
onCancel?.('sky', 'Press on the ground, not the sky');
|
||||
onCancel?.('sky', msg('Press on the ground, not the sky'));
|
||||
return;
|
||||
}
|
||||
anchor = point;
|
||||
@@ -169,7 +170,8 @@ export function initImageryBoxTool({
|
||||
corner = worldAt(event.position) || corner;
|
||||
const box = dragBox();
|
||||
const result = validateBox(box);
|
||||
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
|
||||
if (result.reason === 'degenerate')
|
||||
result.message = msg('Drag to size the box');
|
||||
endDrag();
|
||||
setActive(false);
|
||||
if (result.ok) onBox(result.box);
|
||||
|
||||
@@ -130,9 +130,12 @@ export class LayerBindings {
|
||||
]) {
|
||||
const layer = this._dataManager?.layers?.get(id)?.module;
|
||||
if (typeof layer?.attachShellServices !== 'function') continue;
|
||||
// The Cockpit refusal names the destination: a cyclone layer flies to
|
||||
// a storm, the others to their coverage area.
|
||||
const noun = id === 'weather-cyclones' ? 'cyclone' : 'weather';
|
||||
layer.attachShellServices({
|
||||
runNavigation: (navigate) =>
|
||||
this.runImmediateNavigation('weather', navigate),
|
||||
this.runImmediateNavigation(noun, navigate),
|
||||
imageryHost: this.services.imageryHost,
|
||||
});
|
||||
this._weatherShellModules.push(layer);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Layer and source names as the page shows them.
|
||||
*
|
||||
* Layer ids, English module names (`layer.name`) and declared source names
|
||||
* stay English in layer state, share links and AI tool output; these helpers
|
||||
* translate them only at a presentation boundary. The layer panel, the HUD
|
||||
* and the voice card name a layer the same way through them.
|
||||
*
|
||||
* Pure: imports only i18n, no DOM.
|
||||
* @module ui/layerNames
|
||||
*/
|
||||
import { msg, t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
/** English module names (`layer.name`) by layer id. */
|
||||
export const LAYER_NAME_KEYS = Object.freeze({
|
||||
flights: msg('Live Flights'),
|
||||
military: msg('Military Flights'),
|
||||
earthquakes: msg('Earthquakes (24h)'),
|
||||
satellites: msg('Satellites'),
|
||||
'rocket-launches': msg('Space Missions (30d)'),
|
||||
traffic: msg('Street Traffic'),
|
||||
cctv: msg('CCTV'),
|
||||
radio: msg('Radio'),
|
||||
bikeshare: msg('Bikeshare'),
|
||||
'ais-live-vessels': msg('Live AIS Vessels'),
|
||||
'military-installations': msg('Mapped Installations'),
|
||||
'military-awareness': msg('Global Context'),
|
||||
'local-datacenters': msg('Datacenters'),
|
||||
'local-dams': msg('Dams'),
|
||||
'telegeography-submarine-cables': msg('Submarine Cables'),
|
||||
'local-firms': msg('FIRMS Active Fires'),
|
||||
'local-adsb': msg('Local ADS-B'),
|
||||
transit: msg('Transit'),
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
'gnss-integrity': msg('GNSS Integrity'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
'weather-lightning': msg('Lightning density'),
|
||||
'weather-cyclones': msg('Cyclone advisories'),
|
||||
directions: msg('Directions'),
|
||||
'alpr-cameras': msg('ALPR Cameras'),
|
||||
});
|
||||
|
||||
/** The layer panel's own English names, where they differ from the module's. */
|
||||
export const PANEL_LABEL_KEYS = Object.freeze({
|
||||
'ais-live-vessels': msg('Live Vessels'),
|
||||
bikeshare: msg('Bike Share'),
|
||||
cctv: msg('Cameras'),
|
||||
'street-level': msg('Street Level'),
|
||||
'alpr-cameras': msg('Mapped ALPR Cameras'),
|
||||
'local-datacenters': msg('Data Centers'),
|
||||
'local-firms': msg('Active Fires'),
|
||||
});
|
||||
|
||||
// Looked up by the English text, so a surface that holds `layer.name` keeps
|
||||
// its English data and only the shown text translates.
|
||||
const MODULE_NAME_KEYS = new Map(
|
||||
[...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map(
|
||||
(key) => [key, key],
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate a layer's English module name for display; any other text
|
||||
* (a provider name, an unknown layer) is returned unchanged.
|
||||
* @param {string} name English `layer.name`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localizeLayerName(name) {
|
||||
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
|
||||
return key ? t(key) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer id's module name in the active language, or null for an id this
|
||||
* module does not know.
|
||||
* @param {string} id Layer id, e.g. `earthquakes`.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function layerNameForId(id) {
|
||||
const key = Object.hasOwn(LAYER_NAME_KEYS, id)
|
||||
? LAYER_NAME_KEYS[id]
|
||||
: Object.hasOwn(PANEL_LABEL_KEYS, id)
|
||||
? PANEL_LABEL_KEYS[id]
|
||||
: null;
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A layer as shown: its English module name or its id, whichever is known,
|
||||
* in the active language; otherwise the text as given.
|
||||
* @param {string} [name] English `layer.name`, or an id when the layer had none.
|
||||
* @param {string} [id] Layer id.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function shownLayerName(name, id) {
|
||||
const text = String(name ?? '').trim();
|
||||
if (text && MODULE_NAME_KEYS.has(text)) return t(MODULE_NAME_KEYS.get(text));
|
||||
return layerNameForId(id || text) || text || String(id ?? '');
|
||||
}
|
||||
|
||||
// Layer-declared source names. Provider names (USGS, CelesTrak, GTFS-RT…)
|
||||
// are not listed and pass through unchanged unless the catalog knows them.
|
||||
const SOURCE_LABEL_KEYS = new Map(
|
||||
[
|
||||
msg('Flights'),
|
||||
msg('Military aircraft'),
|
||||
msg('Vessels'),
|
||||
msg('CCTV + Street View fallback'),
|
||||
msg('OpenStreetMap + optional Google Maps Places'),
|
||||
msg('GFS / ECMWF IFS · FORECAST'),
|
||||
msg('NOAA nowCOAST · OBSERVED'),
|
||||
msg('NASA FIRMS · LIVE'),
|
||||
msg('OSM routing'),
|
||||
msg('Mapped camera locations'),
|
||||
msg('OpenStreetMap · community mapped'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* A layer's declared source name as shown.
|
||||
* @param {string} source English source name, e.g. `Flights` or `OpenSky`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sourceLabel(source) {
|
||||
if (typeof source !== 'string') return source;
|
||||
if (source === 'Local') return t('Local', { context: 'data source' });
|
||||
const key = SOURCE_LABEL_KEYS.get(source);
|
||||
if (key) return t(key);
|
||||
// A layer with several providers joins their names ("Mapillary · Google
|
||||
// Street View"); a name with a translation shows it.
|
||||
return source.includes(' · ')
|
||||
? source.split(' · ').map(translateKnown).join(' · ')
|
||||
: translateKnown(source);
|
||||
}
|
||||
+113
-103
@@ -4,8 +4,23 @@ import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
|
||||
// Layer and source names at this presentation boundary: registry ids and
|
||||
// English module names stay as they are; a layer without an entry renders its
|
||||
// module name, and the panel's own English names win (PANEL_LABEL_KEYS).
|
||||
import {
|
||||
LAYER_NAME_KEYS,
|
||||
PANEL_LABEL_KEYS as PANEL_LABELS,
|
||||
sourceLabel,
|
||||
} from './layerNames.js';
|
||||
export { localizeLayerName } from './layerNames.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { formatNumber, msg, t, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
|
||||
// hooks, CSS feed-* classes); only the final label value translates, at this
|
||||
@@ -20,39 +35,6 @@ const FEED_STATE_LABEL_KEYS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
});
|
||||
|
||||
// Layer display names, localized at the same presentation boundary. Registry
|
||||
// ids stay English; a layer without an entry here renders its module name,
|
||||
// falling back to the English panel override when one exists.
|
||||
const LAYER_NAME_KEYS = Object.freeze({
|
||||
flights: msg('Live Flights'),
|
||||
military: msg('Military Flights'),
|
||||
earthquakes: msg('Earthquakes (24h)'),
|
||||
satellites: msg('Satellites'),
|
||||
'rocket-launches': msg('Space Missions (30d)'),
|
||||
traffic: msg('Street Traffic'),
|
||||
cctv: msg('CCTV'),
|
||||
radio: msg('Radio'),
|
||||
bikeshare: msg('Bikeshare'),
|
||||
'ais-live-vessels': msg('Live AIS Vessels'),
|
||||
'military-installations': msg('Mapped Installations'),
|
||||
'military-awareness': msg('Global Context'),
|
||||
'local-datacenters': msg('Datacenters'),
|
||||
'local-dams': msg('Dams'),
|
||||
'telegeography-submarine-cables': msg('Submarine Cables'),
|
||||
'local-firms': msg('FIRMS Active Fires'),
|
||||
'local-adsb': msg('Local ADS-B'),
|
||||
transit: msg('Transit'),
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
'gnss-integrity': msg('GNSS Integrity'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
'weather-lightning': msg('Lightning density'),
|
||||
'weather-cyclones': msg('Cyclone advisories'),
|
||||
directions: msg('Directions'),
|
||||
});
|
||||
|
||||
function lifecycleStateLabel(lifecycleState) {
|
||||
return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
|
||||
}
|
||||
@@ -126,15 +108,6 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) =>
|
||||
const PANEL_POSITIONS = new Map(
|
||||
PANEL_ORDER.map(({ id }, index) => [id, index]),
|
||||
);
|
||||
const PANEL_LABELS = {
|
||||
'ais-live-vessels': msg('Live Vessels'),
|
||||
bikeshare: msg('Bike Share'),
|
||||
cctv: msg('Cameras'),
|
||||
'street-level': msg('Street Level'),
|
||||
'alpr-cameras': msg('Mapped ALPR Cameras'),
|
||||
'local-datacenters': msg('Data Centers'),
|
||||
'local-firms': msg('Active Fires'),
|
||||
};
|
||||
|
||||
function englishPanelLabel(layer) {
|
||||
return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name;
|
||||
@@ -147,59 +120,6 @@ function panelLabel(layer) {
|
||||
return key ? t(key) : layer.name;
|
||||
}
|
||||
|
||||
// English module names (layer.name) that other surfaces print, such as the
|
||||
// loading chip's layer roster. Looked up by the English text so that surface
|
||||
// keeps its English output and only the presentation translates.
|
||||
const MODULE_NAME_KEYS = new Map(
|
||||
[
|
||||
...Object.values(LAYER_NAME_KEYS),
|
||||
...Object.values(PANEL_LABELS),
|
||||
msg('ALPR Cameras'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate a layer's English module name for display; any other text
|
||||
* (a provider name, an unknown layer) is returned unchanged.
|
||||
* @param {string} name English `layer.name`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localizeLayerName(name) {
|
||||
const key = MODULE_NAME_KEYS.get(String(name ?? '').trim());
|
||||
return key ? t(key) : name;
|
||||
}
|
||||
|
||||
// Layer-declared source names stay English in layer state, share links and AI
|
||||
// tool output; only the row's source line translates them. Provider names
|
||||
// (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged.
|
||||
const SOURCE_LABEL_KEYS = new Map(
|
||||
[
|
||||
msg('Flights'),
|
||||
msg('Military aircraft'),
|
||||
msg('Vessels'),
|
||||
msg('CCTV + Street View fallback'),
|
||||
msg('OpenStreetMap + optional Google Maps Places'),
|
||||
msg('GFS / ECMWF IFS · FORECAST'),
|
||||
msg('NOAA nowCOAST · OBSERVED'),
|
||||
msg('NASA FIRMS · LIVE'),
|
||||
msg('OSM routing'),
|
||||
msg('Mapped camera locations'),
|
||||
msg('OpenStreetMap · community mapped'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
function sourceLabel(source) {
|
||||
if (typeof source !== 'string') return source;
|
||||
if (source === 'Local') return t('Local', { context: 'data source' });
|
||||
const key = SOURCE_LABEL_KEYS.get(source);
|
||||
if (key) return t(key);
|
||||
// A layer with several providers joins their names ("Mapillary · Google
|
||||
// Street View"); a name with a translation shows it.
|
||||
return source.includes(' · ')
|
||||
? source.split(' · ').map(translateKnown).join(' · ')
|
||||
: translateKnown(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guidance for a control a missing provider key is holding back.
|
||||
*
|
||||
@@ -224,6 +144,92 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Live-source failures arrive as English data (src/sources/live/contract.js,
|
||||
// standalone.js, flights and military ingestion), some with a source name or a
|
||||
// number in them; the row shows them in the UI language. Keep these in step
|
||||
// with the producers. Anything else shows its catalog entry, or as it came.
|
||||
const FEED_ERRORS = new Set([
|
||||
msg('OpenSky OAuth client missing/invalid'),
|
||||
msg('OpenSky OAuth rejected credentials'),
|
||||
msg('OpenSky username/password rejected'),
|
||||
msg('OpenSky auth missing'),
|
||||
msg('OpenSky auth required'),
|
||||
msg('OpenSky auth invalid'),
|
||||
msg('OpenSky auth failed'),
|
||||
]);
|
||||
const FEED_ERROR_PATTERNS = [
|
||||
// Street Level coverage errors (streetLevel/providers/kartaview/source.js),
|
||||
// ahead of the generic "<source> HTTP <status>".
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
// Weather imagery (weather/source.js, infraredImage.js) and vector tile
|
||||
// (sources/vectorTiles.js) failures carry their own wording; the generic
|
||||
// pattern below would take "Weather image" for a source name.
|
||||
[
|
||||
/^Weather (image )?HTTP (\d+)$/,
|
||||
([, image, status]) =>
|
||||
image
|
||||
? t('Weather image HTTP {status}', { status })
|
||||
: t('Weather HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Vector tiles unavailable \(HTTP (\d+)\)$/,
|
||||
([, status]) => t('Vector tiles unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
[
|
||||
/^Source snapshot (\d+) min old$/,
|
||||
([, minutes]) => t('Source snapshot {minutes} min old', { minutes }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited \(anonymous\)$/,
|
||||
([, source]) =>
|
||||
t('{source} rate limited (anonymous)', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) rate limited$/,
|
||||
([, source]) => t('{source} rate limited', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^(.+) HTTP (\d{3})$/,
|
||||
([, source, status]) =>
|
||||
t('{source} HTTP {status}', { source: sourceLabel(source), status }),
|
||||
],
|
||||
[
|
||||
/^(.+) network error$/,
|
||||
([, source]) =>
|
||||
t('{source} network error', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) aircraft rows$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} aircraft rows', { source: sourceLabel(source) }),
|
||||
],
|
||||
[
|
||||
/^Malformed (.+) response$/,
|
||||
([, source]) =>
|
||||
t('Malformed {source} response', { source: sourceLabel(source) }),
|
||||
],
|
||||
];
|
||||
|
||||
/** A layer's error text in the UI language (see FEED_ERRORS). */
|
||||
export function translateFeedError(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
if (FEED_ERRORS.has(text)) return t(text);
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
/** A loading label is English data, at times several joined by " · ". */
|
||||
@@ -571,15 +577,19 @@ export class LayerPanel {
|
||||
|
||||
/**
|
||||
* Write a row's count. A count that is a phrase carrying its own number
|
||||
* ("24 nearby · 2 on screen") moves to its own line under the name: beside
|
||||
* the toggle it squeezes the name to a word per line in any language. The
|
||||
* "—" placeholder is marked so it can give way to a wide status chip.
|
||||
* ("24 nearby · 2 on screen") moves to its own line under the name outside
|
||||
* English: beside the toggle it squeezes a longer translated name to a word
|
||||
* per line. English keeps the count beside the toggle. The "—" placeholder
|
||||
* is marked so it can give way to a wide status chip (translations only,
|
||||
* radio.css).
|
||||
*/
|
||||
_syncCount(row, count, stats) {
|
||||
const text = this._layerCountText(stats);
|
||||
setRowText(count, text);
|
||||
const phrase =
|
||||
typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel);
|
||||
getLocale() !== 'en' &&
|
||||
typeof stats.countLabel === 'string' &&
|
||||
/\d/.test(stats.countLabel);
|
||||
row.classList.toggle('data-toggle-row--count-line', phrase);
|
||||
count.classList.toggle('data-count-empty', text === '—');
|
||||
}
|
||||
@@ -743,7 +753,7 @@ export class LayerPanel {
|
||||
{ source },
|
||||
);
|
||||
}
|
||||
const presentedError = translateKnown(
|
||||
const presentedError = translateFeedError(
|
||||
stats.error || stats.lastError || stats.managerRefreshError,
|
||||
);
|
||||
if (presentedError) {
|
||||
@@ -768,7 +778,7 @@ export class LayerPanel {
|
||||
typeof stats.statusMessage === 'string' &&
|
||||
stats.statusMessage.trim()
|
||||
) {
|
||||
return `${source} · ${stats.statusMessage.trim()}`;
|
||||
return `${source} · ${translateKnown(stats.statusMessage.trim())}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
|
||||
if (stats.loading) {
|
||||
@@ -885,7 +895,7 @@ export class LayerPanel {
|
||||
: stateText;
|
||||
const keyGuidance = unavailable
|
||||
? String(
|
||||
translateKnown(layer.stats.error) || t('Data source unavailable'),
|
||||
translateFeedError(layer.stats.error) || t('Data source unavailable'),
|
||||
)
|
||||
: layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('Local ADS-B guidance hint is translated in the layer row (K-20)', async () => {
|
||||
const { setLocale, t } = await import('../i18n/index.js');
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const { localAdsbStatus } = await import('../layers/localAdsb/status.js');
|
||||
const english = localAdsbStatus({
|
||||
receiver: { webUsbSupported: true, connected: false },
|
||||
feedState: null,
|
||||
heard: 0,
|
||||
});
|
||||
assert.equal(
|
||||
LayerPanel.prototype._buildMetaText.call(LayerPanel.prototype, {
|
||||
id: 'local-adsb',
|
||||
enabled: true,
|
||||
lifecycleState: 'enabled',
|
||||
stats: { count: 0, lastUpdate: null, ...english },
|
||||
}),
|
||||
'RTL-SDR · WebUSB · connect a receiver in Radio',
|
||||
);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const receiver of [
|
||||
{ webUsbSupported: true, connected: false },
|
||||
{ webUsbSupported: false, connected: false },
|
||||
{ webUsbSupported: true, connected: true, mode: 'fm' },
|
||||
]) {
|
||||
const stats = localAdsbStatus({ receiver, feedState: null, heard: 0 });
|
||||
const text = LayerPanel.prototype._buildMetaText.call(
|
||||
LayerPanel.prototype,
|
||||
{
|
||||
id: 'local-adsb',
|
||||
enabled: true,
|
||||
lifecycleState: 'enabled',
|
||||
stats: { count: 0, lastUpdate: null, ...stats },
|
||||
},
|
||||
);
|
||||
assert.notEqual(
|
||||
t(stats.statusMessage),
|
||||
stats.statusMessage,
|
||||
`${locale}: no catalog entry for "${stats.statusMessage}"`,
|
||||
);
|
||||
assert.ok(
|
||||
!text.includes(stats.statusMessage),
|
||||
`${locale}: row shows English hint: ${text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
setLocale('en');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { localSdrCardView } from './localSdrPresentation.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const fmStreaming = {
|
||||
webUsbSupported: true,
|
||||
connected: true,
|
||||
status: 'streaming',
|
||||
message: 'Streaming',
|
||||
mode: 'fm',
|
||||
frequencyHz: 98_500_000,
|
||||
volume: 0.8,
|
||||
gain: 'auto',
|
||||
seeking: false,
|
||||
seekMessage: '',
|
||||
decodedMessages: 0,
|
||||
messagesPerSecond: null,
|
||||
aircraftHeard: 0,
|
||||
aircraftPositioned: 0,
|
||||
deviceLabel: 'RTL2838',
|
||||
locationStatus: 'unknown',
|
||||
audioState: 'running',
|
||||
samplesPerSecond: 2_400_000,
|
||||
workerBlocks: 3,
|
||||
iqLevelDbfs: -20,
|
||||
audioLevelDbfs: -12,
|
||||
};
|
||||
|
||||
test('FM status line names the AudioContext state in the UI language', () => {
|
||||
try {
|
||||
for (const [locale, state] of [
|
||||
['es', 'running'],
|
||||
['ru', 'running'],
|
||||
['es', 'suspended'],
|
||||
['ru', 'closed'],
|
||||
['es', 'idle'],
|
||||
['ru', 'idle'],
|
||||
]) {
|
||||
setLocale(locale);
|
||||
const text = localSdrCardView({
|
||||
...fmStreaming,
|
||||
audioState: state,
|
||||
}).statusText;
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
new RegExp(`audio[: ]+${state}\\b|аудио[: ]+${state}\\b`),
|
||||
`${locale}: ${text}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('FM status line keeps the English AudioContext state words', () => {
|
||||
setLocale('en');
|
||||
for (const state of ['running', 'suspended', 'closed', 'idle']) {
|
||||
const text = localSdrCardView({
|
||||
...fmStreaming,
|
||||
audioState: state,
|
||||
}).statusText;
|
||||
assert.ok(text.endsWith(` · audio ${state}`), text);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
internetRadioAudioActive,
|
||||
localSdrCardView,
|
||||
@@ -198,7 +199,7 @@ export class LocalSdrControls {
|
||||
// Internet radio just started: it takes over from local FM.
|
||||
if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) {
|
||||
void this.receiver.stop({
|
||||
message: 'Local FM stopped: internet radio started',
|
||||
message: t('Local FM stopped: internet radio started'),
|
||||
});
|
||||
}
|
||||
this._radioAudioActive = radioAudioActive;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { localSdrFeedLine } from './localSdrPresentation.js';
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`decoder feed line is translated in ${locale}`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const feeds = [{ id: '1090', band: '1090', status: 'live' }];
|
||||
const polling = localSdrFeedLine({
|
||||
configured: true,
|
||||
polling: true,
|
||||
feeds: [{ ...feeds[0], status: 'unreachable' }],
|
||||
});
|
||||
const idle = localSdrFeedLine({
|
||||
configured: true,
|
||||
polling: false,
|
||||
feeds,
|
||||
});
|
||||
assert.doesNotMatch(polling, /Decoder feeds|unreachable/);
|
||||
assert.match(polling, /1090/);
|
||||
assert.doesNotMatch(idle, /Decoder feeds|read while Local ADS-B is on/);
|
||||
assert.match(idle, /1090/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('an unknown feed status is shown as given', () => {
|
||||
setLocale('en');
|
||||
const feeds = [{ band: '1090', status: 'odd' }];
|
||||
assert.equal(
|
||||
localSdrFeedLine({ configured: true, polling: true, feeds }),
|
||||
'Decoder feeds: 1090 odd',
|
||||
);
|
||||
});
|
||||
|
||||
// B6: a feed status agrees with "fuente" in Spanish; the shared "invalid" key
|
||||
// (any other masculine noun) is left alone.
|
||||
test('es: an invalid decoder feed reads "no válida"', () => {
|
||||
setLocale('es');
|
||||
try {
|
||||
const line = localSdrFeedLine({
|
||||
configured: true,
|
||||
polling: true,
|
||||
feeds: [
|
||||
{ band: '1090', status: 'live' },
|
||||
{ band: '1090', status: 'invalid' },
|
||||
],
|
||||
});
|
||||
assert.match(line, /no válida/);
|
||||
assert.doesNotMatch(line, /no válido\b/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { SdrController } from '../sdr/controller.js';
|
||||
import { LocalSdrControls } from './localSdrControls.js';
|
||||
import { localSdrCardView } from './localSdrPresentation.js';
|
||||
|
||||
function stubDocument() {
|
||||
const elements = new Map();
|
||||
const doc = {
|
||||
activeElement: null,
|
||||
getElementById(id) {
|
||||
if (!elements.has(id)) {
|
||||
const listeners = new Map();
|
||||
elements.set(id, {
|
||||
id,
|
||||
value: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
ownerDocument: doc,
|
||||
classList: { toggle() {} },
|
||||
setAttribute() {},
|
||||
closest: () => null,
|
||||
addEventListener(type, handler) {
|
||||
listeners.set(type, handler);
|
||||
},
|
||||
});
|
||||
}
|
||||
return elements.get(id);
|
||||
},
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
function stubRadio() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
stopPlayback() {},
|
||||
emit(state) {
|
||||
for (const fn of listeners) fn(state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function stopLocalFmForInternetRadio(t, locale) {
|
||||
setLocale(locale);
|
||||
const receiver = new SdrController({ webUsb: {}, storage: null });
|
||||
// Pretend local FM is streaming (no real device: stop() has nothing to tear down).
|
||||
receiver._setState({ connected: true, status: 'streaming', mode: 'fm' });
|
||||
const radio = stubRadio();
|
||||
const controls = new LocalSdrControls({
|
||||
document: stubDocument(),
|
||||
receiver,
|
||||
radio,
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
setLocale('en');
|
||||
});
|
||||
|
||||
radio.emit({ audioState: 'playing' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(receiver.state.connected, false);
|
||||
return localSdrCardView(receiver.state);
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`FM stop message after internet radio start is shown in ${locale}`, async (t) => {
|
||||
const view = await stopLocalFmForInternetRadio(t, locale);
|
||||
assert.notEqual(
|
||||
view.connectLabel,
|
||||
'CONNECT',
|
||||
'surrounding UI is translated',
|
||||
);
|
||||
assert.notEqual(
|
||||
view.statusText,
|
||||
'Local FM stopped: internet radio started',
|
||||
`status must not be the English literal under ${locale}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('FM stop message after internet radio start reads as before in English', async (t) => {
|
||||
const view = await stopLocalFmForInternetRadio(t, 'en');
|
||||
assert.equal(view.statusText, 'Local FM stopped: internet radio started');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// C14: the SDR status line and stats write numbers in the UI language, like
|
||||
// the frequency next to them ("Сканирование 101,1 МГц…").
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { localSdrCardView } from './localSdrPresentation.js';
|
||||
|
||||
const fm = {
|
||||
connected: true,
|
||||
status: 'streaming',
|
||||
mode: 'fm',
|
||||
message: 'Tuned',
|
||||
seekMessage: '',
|
||||
samplesPerSecond: 2_400_000,
|
||||
iqLevelDbfs: -21.44,
|
||||
audioLevelDbfs: -18.26,
|
||||
workerBlocks: 0,
|
||||
audioState: 'running',
|
||||
gain: 20.7,
|
||||
frequencyHz: 101_100_000,
|
||||
volume: 0.5,
|
||||
messagesPerSecond: 13.8,
|
||||
webUsbSupported: true,
|
||||
};
|
||||
|
||||
test('en: SDR readings keep their English digits', () => {
|
||||
setLocale('en');
|
||||
const view = localSdrCardView(fm);
|
||||
assert.match(view.statusText, /· 2\.40 MS\/s IQ ·/);
|
||||
assert.match(view.statusText, /· RF -21\.4 dBFS ·/);
|
||||
assert.match(view.statusText, /-18\.3 dBFS audio/);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: SDR readings use the locale's decimal comma`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const view = localSdrCardView(fm);
|
||||
assert.match(view.statusText, /· 2,40 MS\/s IQ ·/);
|
||||
assert.match(view.statusText, /· RF -21,4 dBFS ·/);
|
||||
assert.match(view.statusText, /-18,3 dBFS/);
|
||||
const adsb = localSdrCardView({ ...fm, mode: 'adsb' });
|
||||
assert.equal(adsb.stats.rate, '13,8');
|
||||
assert.equal(adsb.stats.iq, '-21,4 dBFS');
|
||||
assert.equal(adsb.gainValue, '20.7', 'input values stay machine');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
|
||||
import { msg, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, msg, t, tn } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
/**
|
||||
@@ -22,6 +22,39 @@ function statusLabel(status) {
|
||||
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
|
||||
}
|
||||
|
||||
// AudioContext.state values (plus the controller's "idle") as shown in the
|
||||
// FM status line; the value itself stays machine.
|
||||
const AUDIO_STATE_LABELS = Object.freeze({
|
||||
idle: msg('idle'),
|
||||
running: msg('running'),
|
||||
suspended: msg('suspended'),
|
||||
closed: msg('closed'),
|
||||
});
|
||||
|
||||
function audioStateLabel(audioState) {
|
||||
const key = String(audioState || 'idle');
|
||||
return AUDIO_STATE_LABELS[key] ? t(AUDIO_STATE_LABELS[key]) : key;
|
||||
}
|
||||
|
||||
// Decoder feed statuses from the server route, as shown on the card. They
|
||||
// carry their own context: the word agrees with "feed" (es "fuente no válida"),
|
||||
// unlike the shared "invalid" / "stale" elsewhere.
|
||||
function feedStatusLabel(status) {
|
||||
const key = String(status || 'unreachable');
|
||||
switch (key) {
|
||||
case 'live':
|
||||
return t('live', { context: 'feed status' });
|
||||
case 'stale':
|
||||
return t('stale', { context: 'feed status' });
|
||||
case 'unreachable':
|
||||
return t('unreachable', { context: 'feed status' });
|
||||
case 'invalid':
|
||||
return t('invalid', { context: 'feed status' });
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for a numeric gain option: the option value stays the machine value
|
||||
* ("28.0"); only the label follows the locale ("28,0 дБ").
|
||||
@@ -39,9 +72,18 @@ function formatGain(gain) {
|
||||
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
|
||||
}
|
||||
|
||||
/** A reading with a fixed number of decimals, in the UI language ("2,40"). */
|
||||
function fixed(value, digits) {
|
||||
return formatNumber(value, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatIq(state) {
|
||||
return Number.isFinite(state.iqLevelDbfs)
|
||||
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
|
||||
? `${fixed(state.iqLevelDbfs, 1)} dBFS`
|
||||
: '--';
|
||||
}
|
||||
|
||||
@@ -49,7 +91,7 @@ function statusText(state) {
|
||||
const fmActive = state.mode === 'fm';
|
||||
const iqStatus =
|
||||
state.samplesPerSecond > 0
|
||||
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
|
||||
? `${fixed(state.samplesPerSecond / 1_000_000, 2)} MS/s IQ`
|
||||
: t('waiting for IQ');
|
||||
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
|
||||
if (state.connected && !fmActive) {
|
||||
@@ -70,9 +112,11 @@ function statusText(state) {
|
||||
: t('DSP waiting');
|
||||
const rfStatus = `RF ${formatIq(state)}`;
|
||||
const audioSignal = Number.isFinite(state.audioLevelDbfs)
|
||||
? t('{level} dBFS audio', { level: state.audioLevelDbfs.toFixed(1) })
|
||||
? t('{level} dBFS audio', { level: fixed(state.audioLevelDbfs, 1) })
|
||||
: t('audio signal --');
|
||||
const audio = t('audio {state}', { state: state.audioState });
|
||||
const audio = t('audio {state}', {
|
||||
state: audioStateLabel(state.audioState),
|
||||
});
|
||||
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · ${audio}`;
|
||||
}
|
||||
return detail;
|
||||
@@ -121,7 +165,7 @@ export function localSdrCardView(state) {
|
||||
statsHidden: !(adsbActive && state.connected),
|
||||
stats: {
|
||||
rate: Number.isFinite(state.messagesPerSecond)
|
||||
? state.messagesPerSecond.toFixed(1)
|
||||
? fixed(state.messagesPerSecond, 1)
|
||||
: '--',
|
||||
heard: String(state.aircraftHeard || 0),
|
||||
positioned: String(state.aircraftPositioned || 0),
|
||||
@@ -174,9 +218,9 @@ export function localSdrFeedLine(feedState) {
|
||||
const names = feeds.map((feed) => {
|
||||
const name = localReceiverFeedName(feed, feeds);
|
||||
return feedState.polling || feed?.status === 'invalid'
|
||||
? `${name} ${feed?.status || 'unreachable'}`
|
||||
? `${name} ${feedStatusLabel(feed?.status)}`
|
||||
: name;
|
||||
});
|
||||
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
|
||||
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
|
||||
const suffix = feedState.polling ? '' : t(' · read while Local ADS-B is on');
|
||||
return `${t('Decoder feeds: {feeds}', { feeds: names.join(' · ') })}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// K-27: the dock language switch reloads the page, so it asks first while the
|
||||
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
|
||||
// a module registers).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import {
|
||||
describeUnsavedWork,
|
||||
msg,
|
||||
registerUnsavedWork,
|
||||
setLocale,
|
||||
} from '../i18n/index.js';
|
||||
|
||||
function fakeButton() {
|
||||
const listeners = {};
|
||||
return {
|
||||
dataset: {},
|
||||
classList: { toggle() {} },
|
||||
setAttribute() {},
|
||||
addEventListener: (type, listener) => (listeners[type] = listener),
|
||||
click: () => listeners.click(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the switch into a fake dock with the given shell state. */
|
||||
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
|
||||
const buttons = [];
|
||||
const previous = {
|
||||
document: globalThis.document,
|
||||
location: globalThis.location,
|
||||
history: globalThis.history,
|
||||
confirm: globalThis.confirm,
|
||||
};
|
||||
const reloads = [];
|
||||
const prompts = [];
|
||||
let answer = true;
|
||||
globalThis.document = {
|
||||
querySelector: () => ({ appendChild: (button) => buttons.push(button) }),
|
||||
createElement: fakeButton,
|
||||
};
|
||||
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,
|
||||
};
|
||||
StyleManager.prototype._initLocaleSelector.call(shell);
|
||||
const button = (locale) =>
|
||||
buttons.find((candidate) => candidate.dataset.locale === locale);
|
||||
return {
|
||||
button,
|
||||
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.button('es').click();
|
||||
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.button('ru').click();
|
||||
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.button('ru').click();
|
||||
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.button('es').click();
|
||||
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.button('en').click();
|
||||
assert.equal(view.prompts.length, 1);
|
||||
assert.doesNotMatch(view.prompts[0], /Switching language/);
|
||||
} finally {
|
||||
view.restore();
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { locationMiniStatus } from '../locationStatus.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { localizePlaceName } from './placeNames.js';
|
||||
|
||||
const POI_KEYS = ['Q', 'W', 'E', 'R', 'T'];
|
||||
|
||||
@@ -51,7 +52,7 @@ export class LocationControls {
|
||||
pill.type = 'button';
|
||||
pill.className = 'location-pill';
|
||||
pill.dataset.locationId = id;
|
||||
pill.textContent = city.name;
|
||||
pill.textContent = localizePlaceName(city.name);
|
||||
this.bind(pill, 'click', () => onCity(id));
|
||||
elements.pills.appendChild(pill);
|
||||
}
|
||||
@@ -142,9 +143,16 @@ export class LocationControls {
|
||||
for (const pill of this.elements.pills.querySelectorAll('.location-pill'))
|
||||
pill.classList.toggle('active', pill.dataset.locationId === id);
|
||||
}
|
||||
renderStatus(state) {
|
||||
renderStatus(record) {
|
||||
if (this.destroyed || !this.elements.statusCity || !this.elements.statusPoi)
|
||||
return;
|
||||
// A preset city shows its name in the UI language; the record stays English.
|
||||
const state = record?.city?.name
|
||||
? {
|
||||
...record,
|
||||
city: { ...record.city, name: localizePlaceName(record.city.name) },
|
||||
}
|
||||
: record;
|
||||
const lines = locationMiniStatus(state);
|
||||
// The initial/placeholder states get catalog keys at the writer so a
|
||||
// non-English boot never leaks the English sentinels back over the
|
||||
|
||||
@@ -119,7 +119,7 @@ export class LocationNavigation {
|
||||
}),
|
||||
onResult: (destination) => {
|
||||
if (destination.outlineUnavailable)
|
||||
this._showToast('Detailed outline unavailable');
|
||||
this._showToast(t('Detailed outline unavailable'));
|
||||
},
|
||||
onError: (error) => console.error('[Search] Geocoding failed:', error),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Own panel position preferences, viewport clamping and drag listeners. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
|
||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||
@@ -32,7 +32,7 @@ const VIEWPORT_MARGIN_PX = 6;
|
||||
*/
|
||||
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
|
||||
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
|
||||
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
|
||||
const PANEL_FLOAT_HINT = msg('Double-click the header to snap the panel back');
|
||||
/** Header children whose own interaction wins over a drag or a snap-back. */
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'input, select, option, textarea, button, a, [role="button"]';
|
||||
@@ -477,7 +477,7 @@ export class PanelPositionControls {
|
||||
if (this._floatHintShown) return;
|
||||
this._floatHintShown = true;
|
||||
}
|
||||
this._showToast(PANEL_FLOAT_HINT);
|
||||
this._showToast(t(PANEL_FLOAT_HINT));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Preset place names as the page shows them.
|
||||
*
|
||||
* The preset cities in src/locations.js keep their English names in data:
|
||||
* those are what voice tools, the AI model, share links and lookups match on.
|
||||
* This helper translates a known English name at a presentation boundary
|
||||
* only; any other place (a geocoded address, a name the model chose) is shown
|
||||
* as given.
|
||||
*
|
||||
* Pure: imports only i18n, no DOM.
|
||||
* @module ui/placeNames
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
// The preset cities (CITY_POIS names, kept in step by placeNames.test.mjs),
|
||||
// and Los Angeles, which voice results name for the analyst's region scopes.
|
||||
const PLACE_NAME_KEYS = new Map(
|
||||
[
|
||||
msg('Austin'),
|
||||
msg('San Francisco'),
|
||||
msg('New York'),
|
||||
msg('Tokyo'),
|
||||
msg('London'),
|
||||
msg('Paris'),
|
||||
msg('Dubai'),
|
||||
msg('Washington DC'),
|
||||
msg('Tallinn'),
|
||||
msg('Los Angeles'),
|
||||
].map((key) => [key, key]),
|
||||
);
|
||||
|
||||
/**
|
||||
* A preset place name in the active language; any other text unchanged.
|
||||
* @param {string} name English place name, e.g. `Tokyo`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localizePlaceName(name) {
|
||||
if (typeof name !== 'string') return name;
|
||||
const key = PLACE_NAME_KEYS.get(name.trim());
|
||||
return key ? t(key) : name;
|
||||
}
|
||||
|
||||
/** The English names this module translates (for tests). */
|
||||
export const KNOWN_PLACE_NAMES = Object.freeze([...PLACE_NAME_KEYS.keys()]);
|
||||
@@ -0,0 +1,82 @@
|
||||
// Preset place names show in the UI language at the presentation boundary
|
||||
// (voice results, location pills, HUD); the preset data stays English.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { CITY_POIS } from '../locations.js';
|
||||
import { KNOWN_PLACE_NAMES, localizePlaceName } from './placeNames.js';
|
||||
import { LocationControls } from './locationControls.js';
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('every preset city has a display name entry', () => {
|
||||
for (const city of Object.values(CITY_POIS))
|
||||
assert.ok(KNOWN_PLACE_NAMES.includes(city.name), city.name);
|
||||
});
|
||||
|
||||
test('en: place names pass through unchanged', () => {
|
||||
setLocale('en');
|
||||
for (const name of KNOWN_PLACE_NAMES)
|
||||
assert.equal(localizePlaceName(name), name);
|
||||
});
|
||||
|
||||
for (const [locale, tokyo, losAngeles] of [
|
||||
['ru', 'Токио', 'Лос-Анджелес'],
|
||||
['es', 'Tokio', 'Los Ángeles'],
|
||||
]) {
|
||||
test(`${locale}: preset names are translated, other places shown as given`, () => {
|
||||
setLocale(locale);
|
||||
assert.equal(localizePlaceName('Tokyo'), tokyo);
|
||||
assert.equal(localizePlaceName('Los Angeles'), losAngeles);
|
||||
assert.equal(localizePlaceName('Ulaanbaatar'), 'Ulaanbaatar');
|
||||
assert.equal(localizePlaceName(undefined), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function element() {
|
||||
return {
|
||||
children: [],
|
||||
dataset: {},
|
||||
textContent: '',
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
},
|
||||
replaceChildren() {
|
||||
this.children = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ru: location pills and the mini-status show the translated city', () => {
|
||||
setLocale('ru');
|
||||
const elements = {
|
||||
pills: element(),
|
||||
poiRow: element(),
|
||||
divider: element(),
|
||||
search: element(),
|
||||
searchToggle: element(),
|
||||
resetButtons: [],
|
||||
statusCity: element(),
|
||||
statusPoi: element(),
|
||||
};
|
||||
const doc = { ...element(), createElement: element };
|
||||
const cities = { tokyo: CITY_POIS.tokyo };
|
||||
const controls = new LocationControls({
|
||||
elements,
|
||||
cities,
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
doc,
|
||||
requestFrame: () => 0,
|
||||
cancelFrame() {},
|
||||
});
|
||||
assert.equal(elements.pills.children[0].textContent, 'Токио');
|
||||
controls.renderStatus({ city: CITY_POIS.tokyo, currentPoi: null });
|
||||
assert.equal(elements.statusCity.textContent, '📍 Токио');
|
||||
assert.equal(CITY_POIS.tokyo.name, 'Tokyo');
|
||||
});
|
||||
@@ -179,9 +179,11 @@ export class RadioControls {
|
||||
'aria-expanded',
|
||||
String(radioExpanded),
|
||||
);
|
||||
const label = radioExpanded
|
||||
? 'Go to expanded Radio section'
|
||||
: 'Expand Radio section in Context';
|
||||
const label = t(
|
||||
radioExpanded
|
||||
? msg('Go to expanded Radio section')
|
||||
: msg('Expand Radio section in Context'),
|
||||
);
|
||||
this._contextRadioToggleBtn.setAttribute('aria-label', label);
|
||||
this._contextRadioToggleBtn.title = label;
|
||||
return;
|
||||
|
||||
@@ -32,10 +32,10 @@ export function renderRadioState(state) {
|
||||
}
|
||||
this._syncContextRadioLauncherState();
|
||||
this._radioLayerState?.classList.toggle('active', enabled);
|
||||
// Lifecycle enum values map to status-chip keys; counts stay machine.
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
if (this._radioLayerState) {
|
||||
// Lifecycle enum values map to status-chip keys; counts stay machine.
|
||||
const lifecycleLabel =
|
||||
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
|
||||
this._radioLayerState.textContent = transitioning
|
||||
? lifecycleLabel
|
||||
: uncertain
|
||||
@@ -307,7 +307,10 @@ export function renderRadioState(state) {
|
||||
buffering: t('Buffering broadcaster stream…'),
|
||||
playing: t('Playing {station}', { station: stationName }),
|
||||
paused: t('Paused {station}', { station: stationName }),
|
||||
error: state.audioError || t('Broadcaster stream unavailable'),
|
||||
// Playback keeps its messages in English (msg()); shown in the UI language.
|
||||
error: state.audioError
|
||||
? t(state.audioError)
|
||||
: t('Broadcaster stream unavailable'),
|
||||
};
|
||||
const voiceSuffix = state.voiceDucked
|
||||
? t(' · muted during voice interaction')
|
||||
@@ -332,7 +335,9 @@ export function renderRadioState(state) {
|
||||
const uncertainMessage = uncertain
|
||||
? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
|
||||
: null;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
// Directory errors are msg() literals from the radio layer's ingestion.
|
||||
const directoryError = state.error ? t(state.error) : null;
|
||||
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || directoryError || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||
this._radioPlaybackState.classList.toggle(
|
||||
'error',
|
||||
Boolean(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
function el() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
classList: {
|
||||
toggle(name, on) {
|
||||
on ? classes.add(name) : classes.delete(name);
|
||||
},
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function render(audioError) {
|
||||
globalThis.document = { getElementById: () => null, activeElement: null };
|
||||
const host = {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioPlaybackState: el(),
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => false,
|
||||
setPanelCollapsed() {},
|
||||
scheduleLayout() {},
|
||||
},
|
||||
};
|
||||
renderRadioState.call(host, {
|
||||
enabled: true,
|
||||
audioState: 'error',
|
||||
audioError,
|
||||
filteredCount: 1,
|
||||
stationCount: 1,
|
||||
volume: 1,
|
||||
});
|
||||
return host._radioPlaybackState.textContent;
|
||||
}
|
||||
|
||||
const PLAYBACK_ERRORS = [
|
||||
'Playback requires a direct click or tap.',
|
||||
'Broadcaster stream could not be started.',
|
||||
'Broadcaster stream is unavailable or blocked by the browser.',
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`radio playback errors are localized in ${locale}`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const message of PLAYBACK_ERRORS) {
|
||||
assert.notEqual(render(message), message, `${locale}: ${message}`);
|
||||
}
|
||||
// Control: the fallback (no audioError) is translated.
|
||||
assert.notEqual(render(null), 'Broadcaster stream unavailable');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('radio playback errors read as before in English', () => {
|
||||
setLocale('en');
|
||||
for (const message of PLAYBACK_ERRORS) {
|
||||
assert.equal(render(message), message);
|
||||
}
|
||||
assert.equal(render(null), 'Broadcaster stream unavailable');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
|
||||
function el() {
|
||||
const attrs = new Map();
|
||||
return {
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { toggle() {}, add() {}, remove() {}, contains: () => false },
|
||||
setAttribute: (k, v) => attrs.set(k, String(v)),
|
||||
getAttribute: (k) => attrs.get(k),
|
||||
attrs,
|
||||
};
|
||||
}
|
||||
|
||||
for (const lifecycleState of ['enabling', 'disabling']) {
|
||||
test(`Radio render survives the ${lifecycleState} transition`, () => {
|
||||
const prior = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
try {
|
||||
const enable = el();
|
||||
const ctx = {
|
||||
destroyed: false,
|
||||
_radioPanel: el(),
|
||||
_radioEnableBtn: enable,
|
||||
_syncContextRadioLauncherState() {},
|
||||
actions: {
|
||||
getLifecycle: () => ({
|
||||
lifecycleState,
|
||||
enabled: lifecycleState === 'disabling',
|
||||
uncertain: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
enabled: false,
|
||||
categories: [],
|
||||
filteredCount: 0,
|
||||
stationCount: 0,
|
||||
audioState: 'idle',
|
||||
};
|
||||
try {
|
||||
renderRadioState.call(ctx, state);
|
||||
} catch (error) {
|
||||
// Anything thrown after the transitional label is a different
|
||||
// missing fixture field, not the bug under test.
|
||||
assert.ok(
|
||||
!(
|
||||
error instanceof ReferenceError &&
|
||||
/lifecycleLabel/.test(error.message)
|
||||
),
|
||||
`render threw: ${error.message}`,
|
||||
);
|
||||
}
|
||||
assert.equal(enable.attrs.get('aria-busy'), 'true');
|
||||
assert.equal(enable.textContent, lifecycleState.toUpperCase());
|
||||
} finally {
|
||||
globalThis.document = prior;
|
||||
}
|
||||
});
|
||||
}
|
||||
+12
-3
@@ -2,9 +2,18 @@ import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
/**
|
||||
* "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather
|
||||
* than cut from the dated readout: the localized day and month vary in length
|
||||
* ("3/9" in Spanish, "09-03" in English).
|
||||
*/
|
||||
export const utcClock = (tick) =>
|
||||
Number.isFinite(Date.parse(tick))
|
||||
? `${new Date(tick).toISOString().slice(11, 16)} UTC`
|
||||
: '';
|
||||
const utc = (tick) =>
|
||||
Number.isFinite(Date.parse(tick))
|
||||
? `${formatUtcDayMonth(tick)} ${new Date(tick).toISOString().slice(11, 16)} UTC`
|
||||
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
|
||||
: '';
|
||||
|
||||
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
|
||||
@@ -115,8 +124,8 @@ export function createRailTimeline({
|
||||
String(Math.max(0, Math.min(props.index, ticks.length - 1))),
|
||||
);
|
||||
valueText(ticks[Number(slider.value)]);
|
||||
set(oldest, 'textContent', utc(ticks[0]).slice(6));
|
||||
set(newest, 'textContent', utc(ticks.at(-1)).slice(6));
|
||||
set(oldest, 'textContent', utcClock(ticks[0]));
|
||||
set(newest, 'textContent', utcClock(ticks.at(-1)));
|
||||
set(readout, 'textContent', props.readout || '');
|
||||
}
|
||||
for (const node of [slider, latest, play])
|
||||
|
||||
+28
-1
@@ -19,6 +19,7 @@ import {
|
||||
} from '../layers/recentImagery/model.js';
|
||||
import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||
|
||||
const PANEL_ID = 'recent-imagery-panel';
|
||||
const OVERVIEW_MIN_BOX_KM = 25;
|
||||
@@ -32,12 +33,38 @@ const LAYER_NOTICES = new Set([
|
||||
msg('Esri map unavailable · no swipe'),
|
||||
msg('Swipe needs a globe map'),
|
||||
msg('Imagery stays on Esri · CLEAR to use Google 3D'),
|
||||
// Box refusals the layer writes itself (layers/recentImagery/index.js).
|
||||
msg('Point the camera at the ground to use the view'),
|
||||
msg('That point cannot anchor a box'),
|
||||
]);
|
||||
const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
|
||||
// Refusals with a value in them (layers/recentImagery/model.js and
|
||||
// imageryBoxTool.js); fixed refusals there are msg() text for translateKnown.
|
||||
const POLAR = /^Imagery stops at ±([\d.]+)° latitude$/;
|
||||
const POINTER_BUSY = /^(.+) is using the pointer — close it first$/;
|
||||
|
||||
function localizeNotice(text) {
|
||||
/**
|
||||
* The notice line in the UI language: notices and box refusals arrive in
|
||||
* English. Exported for tests.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localizeNotice(text) {
|
||||
if (!text) return '';
|
||||
if (LAYER_NOTICES.has(text)) return t(text);
|
||||
const polar = POLAR.exec(text);
|
||||
if (polar)
|
||||
return t('Imagery stops at ±{limit}° latitude', {
|
||||
limit: formatNumber(Number(polar[1]), {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
});
|
||||
const busy = POINTER_BUSY.exec(text);
|
||||
if (busy)
|
||||
return t('{owner} is using the pointer — close it first', {
|
||||
owner: translateKnown(pointerOwnerName(busy[1])),
|
||||
});
|
||||
const tooLarge = TOO_LARGE.exec(text);
|
||||
if (!tooLarge) return translateKnown(text);
|
||||
const values = {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// K-43: box-tool and model refusals on the Recent Imagery notice line are
|
||||
// shown in the UI language; the layer keeps them in English.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
import { localizeNotice } from './recentImagery.js';
|
||||
|
||||
const REFUSALS = [
|
||||
'Drag to size the box', // imageryBoxTool.js, click without drag
|
||||
'Press on the ground, not the sky', // imageryBoxTool.js
|
||||
'draw is using the pointer — close it first', // imageryBoxTool.js, pointer busy
|
||||
'Point the camera at the ground to use the view', // layers/recentImagery/index.js
|
||||
'That point cannot anchor a box', // layers/recentImagery/index.js
|
||||
...[
|
||||
{ west: 170, south: 10, east: -170, north: 20 }, // dateline
|
||||
{ west: 10, south: 80, east: 20, north: 89 }, // polar
|
||||
{ west: 10, south: 10, east: 10, north: 20 }, // degenerate
|
||||
{ west: 10, south: 10, east: 200, north: 20 }, // longitude range
|
||||
{ west: Number.NaN, south: 10, east: 20, north: 20 }, // not finite
|
||||
].map((box) => {
|
||||
const result = validateBox(box);
|
||||
assert.equal(result.ok, false);
|
||||
return result.message;
|
||||
}),
|
||||
];
|
||||
|
||||
// The pointer-busy refusal names the owner by its internal id; the notice
|
||||
// shows the tool's name instead, in English too.
|
||||
const SHOWN_EN = new Map([
|
||||
[
|
||||
'draw is using the pointer — close it first',
|
||||
'Draw is using the pointer — close it first',
|
||||
],
|
||||
]);
|
||||
|
||||
test('K-43 en: refusals read as before', () => {
|
||||
setLocale('en');
|
||||
for (const text of REFUSALS)
|
||||
assert.equal(localizeNotice(text), SHOWN_EN.get(text) ?? text);
|
||||
assert.ok(REFUSALS.includes('Imagery stops at ±85.05° latitude'));
|
||||
});
|
||||
|
||||
test('E3/E12: the pointer owner is named by its tool, never by its id', () => {
|
||||
const owners = {
|
||||
en: {
|
||||
draw: 'Draw',
|
||||
directions: 'Directions',
|
||||
'recent-imagery-box': 'Recent imagery',
|
||||
},
|
||||
ru: {
|
||||
draw: 'Рисование',
|
||||
directions: 'Маршруты',
|
||||
'recent-imagery-box': 'Свежие снимки',
|
||||
},
|
||||
es: {
|
||||
draw: 'Dibujo',
|
||||
directions: 'Indicaciones',
|
||||
'recent-imagery-box': 'Imágenes recientes',
|
||||
},
|
||||
};
|
||||
try {
|
||||
for (const [locale, names] of Object.entries(owners)) {
|
||||
setLocale(locale);
|
||||
for (const [id, name] of Object.entries(names)) {
|
||||
const shown = localizeNotice(
|
||||
`${id} is using the pointer — close it first`,
|
||||
);
|
||||
assert.ok(shown.includes(name), `${locale} ${id}: ${shown}`);
|
||||
assert.ok(!shown.includes(id), `${locale} ${id} leaked: ${shown}`);
|
||||
}
|
||||
}
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
localizeNotice('measure is using the pointer — close it first'),
|
||||
'measure is using the pointer — close it first',
|
||||
'an unregistered owner falls back to its id',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`K-43 ${locale}: box-tool and model refusals are translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const text of REFUSALS)
|
||||
assert.notEqual(localizeNotice(text), text, `untranslated: ${text}`);
|
||||
assert.match(
|
||||
localizeNotice('Imagery stops at ±85.05° latitude'),
|
||||
/85,05/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
localizeNotice('draw is using the pointer — close it first'),
|
||||
/\bdraw\b/,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
+11
-6
@@ -1,5 +1,5 @@
|
||||
/** Own Scene panel listeners and presentation through project reads and actions. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
sceneElements,
|
||||
renderSceneOptions,
|
||||
@@ -46,7 +46,7 @@ export class SceneControls {
|
||||
this.listen(elements.start, 'click', () =>
|
||||
this.run('start', this.read().selectedSceneId),
|
||||
);
|
||||
this.listen(elements.stop, 'click', () => this.run('stop', 'Stopped'));
|
||||
this.listen(elements.stop, 'click', () => this.run('stop', msg('Stopped')));
|
||||
this.listen(elements.import, 'click', () => elements.file?.click());
|
||||
this.listen(elements.file, 'change', async () => {
|
||||
const file = elements.file?.files?.[0];
|
||||
@@ -141,8 +141,8 @@ export class SceneControls {
|
||||
createScene() {
|
||||
if (this.destroyed) return;
|
||||
const name = window.prompt(
|
||||
'New scene name',
|
||||
`Scene ${this.read().scenes.length + 1}`,
|
||||
t('New scene name'),
|
||||
t('Scene {n}', { n: this.read().scenes.length + 1 }),
|
||||
);
|
||||
if (name) this.run('create', name);
|
||||
}
|
||||
@@ -153,7 +153,12 @@ export class SceneControls {
|
||||
const scene = state.scenes.find(
|
||||
(item) => item.id === state.selectedSceneId,
|
||||
);
|
||||
if (scene && window.confirm(`Delete scene "${scene.title}" and all shots?`))
|
||||
if (
|
||||
scene &&
|
||||
window.confirm(
|
||||
t('Delete scene "{scene}" and all shots?', { scene: scene.title }),
|
||||
)
|
||||
)
|
||||
this.run('deleteScene');
|
||||
}
|
||||
|
||||
@@ -221,7 +226,7 @@ export class SceneControls {
|
||||
} else if (!this.destroyed && !this.playbackKeyRemover) {
|
||||
const onKeyDown = (event) => {
|
||||
if (!this.destroyed && event.key === 'Escape' && this.read().running)
|
||||
this.run('stop', 'Stopped (Esc)');
|
||||
this.run('stop', msg('Stopped (Esc)'));
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
this.playbackKeyRemover = () =>
|
||||
|
||||
+11
-7
@@ -87,21 +87,25 @@ export function createSceneDialog(title, onClose) {
|
||||
}
|
||||
/**
|
||||
* The browser draws a file input's button and "no file" text in its own
|
||||
* language. Hide it visually (it stays focusable and is still opened by its
|
||||
* label) and show a translated button and selection line instead.
|
||||
* language. Keep the native input itself as the whole control — stretched
|
||||
* over the row below, fully transparent — so clicking, keyboard focus and
|
||||
* dropping files or folders from the desktop all stay native (a shrunken
|
||||
* input would only accept drops on its own 1 px), and draw a translated
|
||||
* button and selection line underneath it.
|
||||
*/
|
||||
function fileChooser(wrapper, node) {
|
||||
wrapper.style.position = 'relative';
|
||||
Object.assign(node.style, {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
inset: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '0',
|
||||
opacity: '0',
|
||||
cursor: 'pointer',
|
||||
zIndex: '1',
|
||||
});
|
||||
const row = document.createElement('span');
|
||||
row.setAttribute('aria-hidden', 'true');
|
||||
|
||||
+49
-10
@@ -1,9 +1,56 @@
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
|
||||
// label) and outcome: a label dropped into a template cannot take the case or
|
||||
// article the sentence needs in every language. The generic template stays
|
||||
// for a label this table does not know.
|
||||
const SHARE_RESTORE_NOTICES = Object.freeze({
|
||||
expired: Object.freeze({
|
||||
flight: msg('Shared flight follow expired'),
|
||||
'military flight': msg('Shared military flight follow expired'),
|
||||
satellite: msg('Shared satellite follow expired'),
|
||||
}),
|
||||
'source-unavailable': Object.freeze({
|
||||
flight: msg('Shared flight could not be restored — feed unavailable'),
|
||||
'military flight': msg(
|
||||
'Shared military flight could not be restored — feed unavailable',
|
||||
),
|
||||
satellite: msg('Shared satellite could not be restored — feed unavailable'),
|
||||
}),
|
||||
unavailable: Object.freeze({
|
||||
flight: msg('Shared flight is unavailable'),
|
||||
'military flight': msg('Shared military flight is unavailable'),
|
||||
satellite: msg('Shared satellite is unavailable'),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The status notice for a terminal share-tracking restore result.
|
||||
* @param {{classification?: string, label?: string}} result
|
||||
* @returns {string}
|
||||
*/
|
||||
export function shareTrackingRestoreNotice(result) {
|
||||
const outcome =
|
||||
result?.classification === 'expired' ||
|
||||
result?.classification === 'source-unavailable'
|
||||
? result.classification
|
||||
: 'unavailable';
|
||||
const ready = SHARE_RESTORE_NOTICES[outcome][result?.label];
|
||||
if (ready) return t(ready);
|
||||
const subject = result?.label ? t(result.label) : t('entity');
|
||||
return outcome === 'expired'
|
||||
? t('Shared {subject} follow expired', { subject })
|
||||
: outcome === 'source-unavailable'
|
||||
? t('Shared {subject} could not be restored — feed unavailable', {
|
||||
subject,
|
||||
})
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
}
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
@@ -219,15 +266,7 @@ export class ShareRestoration {
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const subject = result.label ? t(result.label) : t('entity');
|
||||
const message =
|
||||
result.classification === 'expired'
|
||||
? t('Shared {subject} follow expired', { subject })
|
||||
: result.classification === 'source-unavailable'
|
||||
? t('Shared {subject} could not be restored — feed unavailable', {
|
||||
subject,
|
||||
})
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
const message = shareTrackingRestoreNotice(result);
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Share-tracking restore notices are ready-made sentences per policy: a label
|
||||
// dropped into a template cannot take the case (ru) or article (es) the
|
||||
// sentence needs. English stays exactly as it was composed before.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { SHARE_TRACKING_RESTORE_POLICIES } from '../data/layerState.js';
|
||||
import { shareTrackingRestoreNotice } from './shareRestoration.js';
|
||||
|
||||
const { flights, military, satellites } = SHARE_TRACKING_RESTORE_POLICIES;
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('en: the notices read as before for every policy and outcome', () => {
|
||||
setLocale('en');
|
||||
const notice = (policy, classification) =>
|
||||
shareTrackingRestoreNotice({ ...policy, classification });
|
||||
assert.equal(notice(flights, 'expired'), 'Shared flight follow expired');
|
||||
assert.equal(
|
||||
notice(military, 'expired'),
|
||||
'Shared military flight follow expired',
|
||||
);
|
||||
assert.equal(
|
||||
notice(satellites, 'source-unavailable'),
|
||||
'Shared satellite could not be restored — feed unavailable',
|
||||
);
|
||||
assert.equal(
|
||||
notice(flights, 'source-unavailable'),
|
||||
'Shared flight could not be restored — feed unavailable',
|
||||
);
|
||||
assert.equal(
|
||||
notice(military, 'unavailable'),
|
||||
'Shared military flight is unavailable',
|
||||
);
|
||||
assert.equal(
|
||||
notice(satellites, 'unavailable'),
|
||||
'Shared satellite is unavailable',
|
||||
);
|
||||
// Unknown label and classification: the generic template still applies.
|
||||
assert.equal(
|
||||
shareTrackingRestoreNotice({ label: 'contact', classification: 'odd' }),
|
||||
'Shared contact is unavailable',
|
||||
);
|
||||
assert.equal(
|
||||
shareTrackingRestoreNotice({ classification: 'expired' }),
|
||||
'Shared entity follow expired',
|
||||
);
|
||||
});
|
||||
|
||||
test('ru: the restore notices do not glue the nominative label into a genitive slot', () => {
|
||||
setLocale('ru');
|
||||
for (const policy of [flights, military, satellites]) {
|
||||
for (const classification of [
|
||||
'expired',
|
||||
'source-unavailable',
|
||||
'unavailable',
|
||||
]) {
|
||||
const message = shareTrackingRestoreNotice({ ...policy, classification });
|
||||
// "Срок отслеживания" takes the genitive: рейса / спутника, never the
|
||||
// nominative label as stored in the policy.
|
||||
assert.doesNotMatch(
|
||||
message,
|
||||
/отслеживания (военный )?(рейс|спутник) /,
|
||||
message,
|
||||
);
|
||||
assert.doesNotMatch(message, /Shared|\{subject\}/, message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('es: the restore notices read with an article before the subject', () => {
|
||||
setLocale('es');
|
||||
const message = shareTrackingRestoreNotice({
|
||||
...flights,
|
||||
classification: 'expired',
|
||||
});
|
||||
assert.doesNotMatch(message, /seguimiento de vuelo/, message);
|
||||
assert.doesNotMatch(message, /Shared/, message);
|
||||
});
|
||||
+52
-9
@@ -9,8 +9,8 @@ import {
|
||||
reduceTrafficSyncFeedback,
|
||||
} from '../loadingFeedback.js';
|
||||
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { localizeLayerName } from './layerPanel.js';
|
||||
import { getLocale, t, msg } from '../i18n/index.js';
|
||||
import { localizeLayerName, translateFeedError } from './layerPanel.js';
|
||||
|
||||
// loadingFeedback.js composes the shared status copy inside the portable
|
||||
// node/browser graph (the server-side HUD summary reads the same modules), so
|
||||
@@ -29,24 +29,66 @@ const STATUS_LABEL_KEYS = Object.freeze({
|
||||
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
|
||||
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
|
||||
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
|
||||
// The other mapped-site failure reasons (src/data/installationFeedback.js),
|
||||
// upper-cased by presentGlobalLoadingStatus.
|
||||
'OVERPASS RATE-LIMITED': msg('OVERPASS RATE-LIMITED'),
|
||||
'OVERPASS TIMED OUT': msg('OVERPASS TIMED OUT'),
|
||||
'OVERPASS COULD NOT COMPLETE THE QUERY': msg(
|
||||
'OVERPASS COULD NOT COMPLETE THE QUERY',
|
||||
),
|
||||
'MAP TILES TEMPORARILY UNAVAILABLE': msg('MAP TILES TEMPORARILY UNAVAILABLE'),
|
||||
'MAPPED NAMES TEMPORARILY UNAVAILABLE': msg(
|
||||
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
|
||||
),
|
||||
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
|
||||
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
|
||||
'syncing road network': msg('syncing road network'),
|
||||
});
|
||||
|
||||
// The ALPR retry chip shows the layer's English error upper-cased
|
||||
// (loadingFeedback.js). These are the camera and vector tile failures it can
|
||||
// carry, by their upper-cased text; each is translated through its own
|
||||
// sentence-case key, the one the layer row shows, and upper-cased again.
|
||||
const CHIP_ERROR_KEYS = new Map(
|
||||
[
|
||||
msg('Vector tile metadata unavailable'),
|
||||
msg('Vector tiles unavailable'),
|
||||
msg('Vector tile request timed out'),
|
||||
msg('Camera coverage unavailable'),
|
||||
msg('Camera source temporarily unavailable'),
|
||||
].map((key) => [key.toUpperCase(), key]),
|
||||
);
|
||||
const CHIP_TILE_HTTP_RE = /^VECTOR TILES UNAVAILABLE \(HTTP (\d+)\)$/;
|
||||
|
||||
function localizeStatusLabel(label) {
|
||||
if (typeof label !== 'string' || !label) return label;
|
||||
const key = STATUS_LABEL_KEYS[label.trim()];
|
||||
return key ? t(key) : label;
|
||||
const text = label.trim();
|
||||
const key = STATUS_LABEL_KEYS[text];
|
||||
if (key) return t(key);
|
||||
const errorKey = CHIP_ERROR_KEYS.get(text);
|
||||
const http = errorKey ? null : CHIP_TILE_HTTP_RE.exec(text);
|
||||
if (!errorKey && !http) return label;
|
||||
return translateFeedError(
|
||||
errorKey || `Vector tiles unavailable (HTTP ${http[1]})`,
|
||||
).toLocaleUpperCase(getLocale());
|
||||
}
|
||||
|
||||
const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
|
||||
// A retry detail is "retrying in Ns" or "retry pending", optionally prefixed
|
||||
// by the ALPR layer name (loadingFeedback.js). The prefix is kept in every
|
||||
// language: it is what tells the chip apart from a mapped-sites retry.
|
||||
const RETRY_DETAIL_RE =
|
||||
/^(?:(ALPR cameras) · )?(?:retrying in (\d+)s|(retry pending))$/;
|
||||
|
||||
function localizeStatusDetail(detail) {
|
||||
if (typeof detail !== 'string' || !detail) return detail;
|
||||
const match = detail.match(RETRY_DETAIL_RE);
|
||||
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
|
||||
if (detail.trim() === 'retry pending') return t('retry pending');
|
||||
const match = detail.trim().match(RETRY_DETAIL_RE);
|
||||
if (match) {
|
||||
const [, prefix, seconds, pending] = match;
|
||||
const retry = pending
|
||||
? t('retry pending')
|
||||
: t('retrying in {seconds}s', { seconds });
|
||||
return prefix ? `${t('ALPR cameras')} · ${retry}` : retry;
|
||||
}
|
||||
return localizeLayerRoster(detail);
|
||||
}
|
||||
|
||||
@@ -64,7 +106,8 @@ function localizeLayerRoster(detail) {
|
||||
);
|
||||
}
|
||||
|
||||
function localizeLoadingPresentation(presentation) {
|
||||
/** The chip's label and detail in the UI language (exported for tests). */
|
||||
export function localizeLoadingPresentation(presentation) {
|
||||
if (!presentation) return presentation;
|
||||
return {
|
||||
...presentation,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// The global loading chip translates the closed status vocabulary of
|
||||
// loadingFeedback.js at the presentation boundary. The ALPR retry detail keeps
|
||||
// its layer-name prefix in every language (it is what tells the chip apart
|
||||
// from a mapped-sites retry), and every mapped-site failure reason label has a
|
||||
// catalog entry, not only the default one.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
const elements = {
|
||||
'global-loading-status': { hidden: true, dataset: {} },
|
||||
'global-loading-detail': { textContent: '' },
|
||||
};
|
||||
globalThis.document ??= {
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
hidden: false,
|
||||
};
|
||||
const { ShellFeedback, localizeLoadingPresentation } =
|
||||
await import('./shellFeedback.js');
|
||||
|
||||
function alprChipDetail(locale, retryInMs) {
|
||||
setLocale(locale);
|
||||
const layer = {
|
||||
id: 'alpr-cameras',
|
||||
name: 'ALPR cameras',
|
||||
enabled: true,
|
||||
stats: {
|
||||
status: 'unavailable',
|
||||
error: 'Unavailable',
|
||||
retryAt: Date.now() + retryInMs,
|
||||
},
|
||||
};
|
||||
const feedback = new ShellFeedback({ readLayers: () => [layer] });
|
||||
feedback._armLoadingFeedbackTicker = () => {};
|
||||
feedback._updateGlobalLoadingFeedback(0);
|
||||
return elements['global-loading-detail'].textContent;
|
||||
}
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('English ALPR retry chip keeps the layer name (as on main)', () => {
|
||||
assert.equal(alprChipDetail('en', 12000), 'ALPR cameras · retrying in 12s');
|
||||
assert.equal(alprChipDetail('en', -1000), 'ALPR cameras · retry pending');
|
||||
});
|
||||
|
||||
test('Spanish ALPR retry chip keeps the layer name', () => {
|
||||
assert.match(alprChipDetail('es', 12000), /ALPR.* · reintento en 12 s/);
|
||||
});
|
||||
|
||||
test('Spanish ALPR retry-pending chip is translated', () => {
|
||||
const detail = alprChipDetail('es', -1000);
|
||||
assert.match(detail, /ALPR/);
|
||||
assert.doesNotMatch(detail, /retry pending/);
|
||||
});
|
||||
|
||||
test('English presentation passes through unchanged', () => {
|
||||
setLocale('en');
|
||||
for (const label of [
|
||||
'OVERPASS TEMPORARILY UNAVAILABLE',
|
||||
'OVERPASS RATE-LIMITED',
|
||||
'OVERPASS TIMED OUT',
|
||||
'OVERPASS COULD NOT COMPLETE THE QUERY',
|
||||
'MAP TILES TEMPORARILY UNAVAILABLE',
|
||||
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
localizeLoadingPresentation({
|
||||
state: 'retry',
|
||||
label,
|
||||
detail: 'retrying in 12s',
|
||||
}),
|
||||
{ state: 'retry', label, detail: 'retrying in 12s' },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: every mapped-site retry label is translated with its detail`, () => {
|
||||
setLocale(locale);
|
||||
for (const label of [
|
||||
'OVERPASS TEMPORARILY UNAVAILABLE',
|
||||
'OVERPASS RATE-LIMITED',
|
||||
'OVERPASS TIMED OUT',
|
||||
'OVERPASS COULD NOT COMPLETE THE QUERY',
|
||||
'MAP TILES TEMPORARILY UNAVAILABLE',
|
||||
'MAPPED NAMES TEMPORARILY UNAVAILABLE',
|
||||
]) {
|
||||
const localized = localizeLoadingPresentation({
|
||||
state: 'retry',
|
||||
label,
|
||||
detail: 'retrying in 12s',
|
||||
});
|
||||
assert.notEqual(localized.label, label, `label stays English: ${label}`);
|
||||
assert.notEqual(localized.detail, 'retrying in 12s');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${locale}: a live provider error label passes through verbatim`, () => {
|
||||
setLocale(locale);
|
||||
const localized = localizeLoadingPresentation({
|
||||
state: 'retry',
|
||||
label: 'HTTP 503 FROM OVERPASS-API.DE',
|
||||
detail: 'retry pending',
|
||||
});
|
||||
assert.equal(localized.label, 'HTTP 503 FROM OVERPASS-API.DE');
|
||||
assert.notEqual(localized.detail, 'retry pending');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Shell toasts, prompts and launcher labels that reach the page through
|
||||
// `showToast?.(`, `this._showToast(`, `window.prompt/confirm` or a constant
|
||||
// are invisible to the unmarked-text checker, so pin them here: marked in the
|
||||
// code, present in every shipped catalog, and no bare literal left at the
|
||||
// call sites.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
extractMessages,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
beginDeferredNavigation,
|
||||
reassertNavigationHandoff,
|
||||
runExplicitNavigation,
|
||||
} from '../navigationPolicy.js';
|
||||
|
||||
const KEYS = [
|
||||
'Tilted view',
|
||||
'Straight-down view',
|
||||
'North up',
|
||||
'Exit cockpit to fly to a camera',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a photo',
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Exit cockpit to fly to a {noun}',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'Double-click the header to snap the panel back',
|
||||
'New scene name',
|
||||
'Delete scene "{scene}" and all shots?',
|
||||
'Detailed outline unavailable',
|
||||
];
|
||||
|
||||
const messages = extractMessages();
|
||||
|
||||
for (const key of KEYS) {
|
||||
test(`marked for translation: ${key}`, () => {
|
||||
assert.ok(messages.has(key), `not marked with t()/msg(): ${key}`);
|
||||
});
|
||||
for (const locale of shippedLocales()) {
|
||||
test(`${locale} catalog translates: ${key}`, () => {
|
||||
const value = readCatalog(locale)[key];
|
||||
assert.ok(value, `missing in ${locale}.json`);
|
||||
assert.notEqual(value, key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('no bare literal reaches showToast in the orientation and navigation code', () => {
|
||||
for (const file of [
|
||||
'../ui/cameraOrientationControls.js',
|
||||
'../navigationPolicy.js',
|
||||
'../ui/locationNavigation.js',
|
||||
'../ui/panelPositionControls.js',
|
||||
]) {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL(file, import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/_?showToast\??\.?\(\s*['"`]/,
|
||||
`${file} passes a bare literal to showToast`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('the Cockpit refusal keeps its English wording per noun', () => {
|
||||
setLocale('en');
|
||||
const log = [];
|
||||
const showToast = (text) => log.push(text);
|
||||
runExplicitNavigation({ cockpitActive: true, noun: 'camera', showToast });
|
||||
beginDeferredNavigation({ cockpitActive: true, showToast });
|
||||
reassertNavigationHandoff({
|
||||
generation: 1,
|
||||
currentGeneration: 1,
|
||||
cockpitActive: true,
|
||||
showToast,
|
||||
});
|
||||
runExplicitNavigation({ cockpitActive: true, noun: 'contact', showToast });
|
||||
assert.deepEqual(log, [
|
||||
'Exit cockpit to fly to a camera',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a location',
|
||||
'Exit cockpit to fly to a contact',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the Cockpit refusal is translated`, () => {
|
||||
setLocale(locale);
|
||||
const log = [];
|
||||
runExplicitNavigation({
|
||||
cockpitActive: true,
|
||||
noun: 'camera',
|
||||
showToast: (text) => log.push(text),
|
||||
});
|
||||
assert.equal(log.length, 1);
|
||||
assert.doesNotMatch(log[0], /Exit cockpit/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// The Street Level meta-line test must not depend on the system locale: the
|
||||
// code formats the count in the app language (formatNumber), so a contributor
|
||||
// whose terminal sets LANG=de_DE or es_ES must see the same green run as CI's
|
||||
// C.UTF-8. Runs the presentation test file under three process locales.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, 'streetLevelPresentation.test.mjs');
|
||||
|
||||
for (const locale of ['de_DE.UTF-8', 'es_ES.UTF-8', 'C.UTF-8']) {
|
||||
test(`streetLevelPresentation tests pass under LC_ALL=${locale}`, () => {
|
||||
// Drop the node:test child markers so the nested run is a real top-level run.
|
||||
const env = { ...process.env, LC_ALL: locale, LANG: locale };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
delete env.NODE_TEST_WORKER_ID;
|
||||
const result = spawnSync(process.execPath, ['--test', target], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
});
|
||||
const failed = result.stdout.match(/^ℹ fail (\d+)/m)?.[1] ?? '?';
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`exit status ${result.status}, ${failed} failing test(s) under ${locale}\n${result.stdout.slice(-700)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -97,6 +97,35 @@ function presentStatus(state) {
|
||||
: { text: t('OFF'), tone: '', pressed, title };
|
||||
}
|
||||
|
||||
// Provider refusals with a number in them stay English data
|
||||
// (providers/kartaview/source.js); they go back onto a placeholder here.
|
||||
const ERROR_PATTERNS = [
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A provider or viewer error in the UI language. Exported for tests.
|
||||
* @param {string} text English error text from a provider.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translateStreetLevelError(text) {
|
||||
if (typeof text !== 'string' || !text) return text;
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** A chip is lit only while the layer and its provider are both on. */
|
||||
function presentProviders(state) {
|
||||
const enabled = state.enabled === true;
|
||||
@@ -107,11 +136,11 @@ function presentProviders(state) {
|
||||
? t('{name} imagery on', { name: provider.name })
|
||||
: t('{name} imagery off', { name: provider.name });
|
||||
if (provider.keyRejected && provider.error)
|
||||
title = `${provider.name}: ${translateKnown(provider.error)}`;
|
||||
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
|
||||
else if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
|
||||
else if (provider.error)
|
||||
title = `${provider.name}: ${translateKnown(provider.error)}`;
|
||||
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
|
||||
return {
|
||||
id: provider.id,
|
||||
label: translateKnown(provider.label),
|
||||
@@ -262,8 +291,9 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
providers: presentProviders(state),
|
||||
error: keyMissing
|
||||
? null
|
||||
: translateKnown(state.street.error || state.coverage.error || '') ||
|
||||
null,
|
||||
: translateStreetLevelError(
|
||||
state.street.error || state.coverage.error || '',
|
||||
) || null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
filterControls: {
|
||||
pano: state.filterControls?.pano !== false,
|
||||
|
||||
@@ -446,9 +446,11 @@ test('the meta line keeps the sequence count and adds the other providers’ hin
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The count follows the app locale (formatNumber under the default 'en'),
|
||||
// not the system locale of the Node process running the tests.
|
||||
assert.equal(
|
||||
view.meta,
|
||||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||||
'2,514 sequences in view · click a line for its photos · click a street to open Street View',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -281,22 +281,16 @@
|
||||
border-top: 3px solid #6be8ff;
|
||||
}
|
||||
|
||||
/* The title takes the full card width and up to two lines, so a translated
|
||||
title keeps its place name; the link sits beside the status below it. */
|
||||
.bhote-embedded-callout footer strong {
|
||||
grid-column: 1 / -1;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer small {
|
||||
grid-column: 1;
|
||||
align-self: center;
|
||||
color: rgba(151, 211, 224, 0.7);
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
@@ -304,14 +298,34 @@
|
||||
|
||||
.bhote-embedded-callout footer a {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
color: #6be8ff;
|
||||
font-size: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* The title takes the full card width and up to two lines, so a translated
|
||||
title keeps its place name; the link sits beside the status below it. */
|
||||
html:not([lang='en']) .bhote-embedded-callout footer strong {
|
||||
grid-column: 1 / -1;
|
||||
display: -webkit-box;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .bhote-embedded-callout footer small {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .bhote-embedded-callout footer a {
|
||||
grid-row: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-embedded-callout footer a:hover,
|
||||
.bhote-embedded-callout footer a:focus-visible {
|
||||
text-decoration: underline;
|
||||
@@ -423,8 +437,9 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
|
||||
color: rgba(174, 208, 216, 0.62);
|
||||
}
|
||||
|
||||
/* Keep "16 OCT 2021" together so the year never wraps alone. */
|
||||
.bhote-event-date {
|
||||
/* Keep a translated date ("16 OCT 2021") together so the year never wraps
|
||||
alone. */
|
||||
html:not([lang='en']) .bhote-event-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -458,7 +473,7 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
|
||||
align-content: center;
|
||||
column-gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 5px 10px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(88, 196, 218, 0.18);
|
||||
background: rgba(6, 19, 27, 0.72);
|
||||
}
|
||||
@@ -470,26 +485,45 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Up to two lines: a translated beat title keeps its subject. Two title lines
|
||||
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
|
||||
.bhote-event-story-current strong {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bhote-event-story-current small {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: rgba(116, 216, 235, 0.7);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .bhote-event-story-current {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
/* Up to two lines: a translated beat title keeps its subject. Two title lines
|
||||
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
|
||||
html:not([lang='en']) .bhote-event-story-current strong {
|
||||
display: -webkit-box;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Phase / time / sources run long in translation: allow a second line. */
|
||||
.bhote-event-story-current small {
|
||||
html:not([lang='en']) .bhote-event-story-current small {
|
||||
display: -webkit-box;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: rgba(116, 216, 235, 0.7);
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
@@ -514,14 +548,22 @@ html:not([lang='en']) .bhote-event-section-head span:last-child {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* One line in English; a longer translation wraps to a second line. */
|
||||
.bhote-event-actions button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 3px 5px;
|
||||
padding: 0 5px;
|
||||
overflow: hidden;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A longer translation wraps to a second line instead of being cut. */
|
||||
html:not([lang='en']) .bhote-event-actions button {
|
||||
padding: 3px 5px;
|
||||
line-height: 1.2;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
@@ -279,12 +279,12 @@ html[lang='es'] .cctv-controls {
|
||||
animation: cctv-frame-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
/* "Acquiring" caption only when no previous frame is showing underneath */
|
||||
.cctv-frame-wrap.loading:not(.has-frame)::before {
|
||||
content: 'ACQUIRING FRAME';
|
||||
/* "Acquiring" caption only when no previous frame is showing underneath.
|
||||
The text lives in the markup (data-i18n) so it is translated. */
|
||||
.cctv-frame-caption {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
@@ -294,6 +294,10 @@ html[lang='es'] .cctv-controls {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes cctv-frame-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
|
||||
+23
-10
@@ -575,7 +575,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
|
||||
text-align: center;
|
||||
vertical-align: -0.08em;
|
||||
}
|
||||
.cockpit-roll-token.is-separator {
|
||||
/* Grouping and decimal separators (a space or comma in Spanish and Russian)
|
||||
get a narrow cell; English keeps the full 0.56em cell. */
|
||||
html:not([lang='en']) .cockpit-roll-token.is-separator {
|
||||
min-width: 0.3em;
|
||||
}
|
||||
.cockpit-roll-token.is-rolling {
|
||||
@@ -1508,9 +1510,11 @@ body.cockpit-mode #view-switcher button {
|
||||
.cockpit-signal-header > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
html:not([lang='en']) .cockpit-signal-header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.cockpit-signal-kicker {
|
||||
html:not([lang='en']) .cockpit-signal-kicker {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1543,7 +1547,7 @@ body.cockpit-mode #view-switcher button {
|
||||
}
|
||||
/* A longer translated state label widens the cycle button; the other
|
||||
controls keep their size instead of being squeezed. */
|
||||
.cockpit-brief-actions > * {
|
||||
html:not([lang='en']) .cockpit-brief-actions > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cockpit-brief-actions button {
|
||||
@@ -1568,12 +1572,15 @@ body.cockpit-mode #view-switcher button {
|
||||
font-size: 14px;
|
||||
}
|
||||
#cockpit-brief-auto {
|
||||
width: auto;
|
||||
min-width: 50px;
|
||||
padding: 0 4px;
|
||||
width: 50px;
|
||||
color: rgba(244, 251, 255, 0.42);
|
||||
font: 700 6px/1 var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
html:not([lang='en']) #cockpit-brief-auto {
|
||||
width: auto;
|
||||
min-width: 50px;
|
||||
padding: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#cockpit-brief-auto[aria-pressed='true'] {
|
||||
@@ -1880,18 +1887,24 @@ body.cockpit-mode #view-switcher button {
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.cockpit-signal-footer > span {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cockpit-brief-tabs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
}
|
||||
.cockpit-brief-tabs button {
|
||||
/* Translated tab names are longer: the tabs keep their words whole and the
|
||||
footer text beside them gives way. */
|
||||
html:not([lang='en']) .cockpit-signal-footer > span {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
html:not([lang='en']) .cockpit-brief-tabs {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
html:not([lang='en']) .cockpit-brief-tabs button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cockpit-brief-tabs button {
|
||||
|
||||
+31
-14
@@ -240,8 +240,6 @@ html:not([lang='en'])
|
||||
}
|
||||
|
||||
.panel-collapse-btn {
|
||||
/* A long translated title must not squeeze the toggle into a sliver. */
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
@@ -258,6 +256,11 @@ html:not([lang='en'])
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* A long translated title must not squeeze the toggle into a sliver. */
|
||||
html:not([lang='en']) .panel-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-collapse-btn:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
color: var(--text-primary);
|
||||
@@ -787,8 +790,9 @@ html:not([lang='en'])
|
||||
/* One label column across the Display rows: --pp-label-chars is the longest
|
||||
label of the active language (set by applicationShell), and the labels are
|
||||
monospace, so every slider track starts at the same x whatever the
|
||||
language. 0.125em is the 1px letter-spacing at 8px. */
|
||||
.pp-slider-row > .pp-slider-mini-label {
|
||||
language. 0.125em is the 1px letter-spacing at 8px. English keeps its
|
||||
own label widths. */
|
||||
html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
flex: 0 0 auto;
|
||||
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
|
||||
}
|
||||
@@ -989,9 +993,9 @@ html:not([lang='en'])
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
/* Equal thirds with room around the label, so a long shape name never runs
|
||||
edge to edge in its segment. */
|
||||
#draw-mode-row .pp-mode-btn {
|
||||
/* Equal thirds with room around the label, so a long translated shape name
|
||||
never runs edge to edge in its segment. */
|
||||
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
|
||||
padding: 0 4px;
|
||||
}
|
||||
#draw-label-row {
|
||||
@@ -1015,9 +1019,11 @@ html:not([lang='en'])
|
||||
.pp-text-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* Sized to its longest colour name so a translated one is never clipped;
|
||||
74px keeps the English width. */
|
||||
.draw-color-select {
|
||||
flex: 0 0 74px;
|
||||
}
|
||||
/* Sized to its longest colour name so a translated one is never clipped. */
|
||||
html:not([lang='en']) .draw-color-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 74px;
|
||||
}
|
||||
@@ -1339,20 +1345,31 @@ body.ui-clean-view #scene-runtime.active {
|
||||
);
|
||||
}
|
||||
|
||||
/* One label column for every parameter, as wide as the longest label in the
|
||||
active language, so all slider tracks start and end at the same x. */
|
||||
#param-sliders {
|
||||
.param-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-slider-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* One label column for every parameter, as wide as the longest translated
|
||||
label, so all slider tracks start and end at the same x. */
|
||||
html:not([lang='en']) #param-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.param-slider-row {
|
||||
html:not([lang='en']) .param-slider-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// K-44 (+ refront-opus-1): layout rules written for long translations must not change the
|
||||
// English rendering ("English renders as before"). Every rule below is read
|
||||
// from the stylesheet without a `html:not([lang='en'])` guard, so it applies
|
||||
// to English too; the expected values are the ones on main (9beadbda).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const css = (name) => readFileSync(join(here, name), 'utf8');
|
||||
|
||||
/** Declarations of every rule whose (unguarded) selector list is `selector`. */
|
||||
function unguardedRules(source, selector) {
|
||||
const norm = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
const want = norm(selector);
|
||||
const out = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(source))) {
|
||||
// The captured "selector" may carry the tail of a preceding block
|
||||
// (comments, an @media prelude); keep what follows the last `}` or `*/`.
|
||||
const raw = m[1].split(/\*\/|\}/).pop();
|
||||
const sel = norm(raw);
|
||||
if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function declaration(block, prop) {
|
||||
const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
|
||||
return m ? m[1].replace(/\s+/g, ' ').trim() : undefined;
|
||||
}
|
||||
|
||||
test('K-44: layer status chip keeps its English width (82px)', () => {
|
||||
const rules = unguardedRules(
|
||||
css('radio.css'),
|
||||
`.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded,
|
||||
.data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial,
|
||||
.data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`,
|
||||
);
|
||||
assert.equal(rules.length, 1);
|
||||
assert.equal(declaration(rules[0], 'min-width'), '82px');
|
||||
});
|
||||
|
||||
test('K-44: Directions steps keep one line with an ellipsis in English', () => {
|
||||
const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text');
|
||||
assert.ok(rule);
|
||||
assert.equal(declaration(rule, 'white-space'), 'nowrap');
|
||||
assert.equal(declaration(rule, 'text-overflow'), 'ellipsis');
|
||||
});
|
||||
|
||||
test('K-44: count-line layout is not reachable from an unguarded selector', () => {
|
||||
const source = css('radio.css');
|
||||
assert.equal(
|
||||
unguardedRules(source, '.data-toggle-row--count-line .data-count').length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('K-44: weather status line keeps its fixed English height', () => {
|
||||
const [rule] = unguardedRules(
|
||||
css('weather.css'),
|
||||
".weather-card [data-line-id='status']",
|
||||
);
|
||||
assert.ok(rule);
|
||||
assert.equal(declaration(rule, 'height'), '1.45em');
|
||||
});
|
||||
|
||||
test('K-44: cockpit number separators keep the English cell width', () => {
|
||||
assert.equal(
|
||||
unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator')
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('K-44: Bhote Koshi callout keeps its English footer layout', () => {
|
||||
const source = css('bhote-koshi.css');
|
||||
const [strong] = unguardedRules(
|
||||
source,
|
||||
'.bhote-embedded-callout footer strong',
|
||||
);
|
||||
assert.equal(declaration(strong, 'white-space'), 'nowrap');
|
||||
const [link] = unguardedRules(source, '.bhote-embedded-callout footer a');
|
||||
assert.equal(declaration(link, 'grid-row'), '1 / span 2');
|
||||
});
|
||||
|
||||
test('K-44: SCENES actions stay on one line in English (refront-opus-1)', () => {
|
||||
assert.equal(
|
||||
unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn')
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
const [select] = unguardedRules(css('scenes.css'), '#scene-select');
|
||||
assert.equal(declaration(select, 'white-space'), undefined);
|
||||
});
|
||||
|
||||
test('K-44: Display and parameter rows keep their English layout', () => {
|
||||
const source = css('controls.css');
|
||||
assert.equal(
|
||||
unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length,
|
||||
0,
|
||||
);
|
||||
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
|
||||
const [row] = unguardedRules(source, '.param-slider-row');
|
||||
assert.equal(declaration(row, 'display'), 'flex');
|
||||
const [color] = unguardedRules(source, '.draw-color-select');
|
||||
assert.equal(declaration(color, 'flex'), '0 0 74px');
|
||||
});
|
||||
|
||||
test('K-44: weather setting rows keep the 56px English label column', () => {
|
||||
const source = css('weather.css');
|
||||
assert.equal(unguardedRules(source, '.rail-card-settings').length, 0);
|
||||
const [row] = unguardedRules(source, '.rail-card-setting');
|
||||
assert.equal(
|
||||
declaration(row, 'grid-template-columns'),
|
||||
'56px minmax(0, 1fr)',
|
||||
);
|
||||
});
|
||||
|
||||
test('K-44: cockpit brief controls keep their English sizes', () => {
|
||||
const source = css('cockpit.css');
|
||||
const [auto] = unguardedRules(source, '#cockpit-brief-auto');
|
||||
assert.equal(declaration(auto, 'width'), '50px');
|
||||
assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0);
|
||||
});
|
||||
|
||||
test('K-44: the count-line class is set only outside English', async () => {
|
||||
const { setLocale } = await import('../../i18n/index.js');
|
||||
const { LayerPanel } = await import('../layerPanel.js');
|
||||
const classes = () => {
|
||||
const toggled = new Map();
|
||||
const node = () => ({
|
||||
textContent: '',
|
||||
classList: { toggle: (name, on) => toggled.set(name, on) },
|
||||
appendChild() {},
|
||||
});
|
||||
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
|
||||
countLabel: '693 nearby · 93 on screen',
|
||||
});
|
||||
return toggled.get('data-toggle-row--count-line');
|
||||
};
|
||||
globalThis.document ??= { createElement: () => ({}) };
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(classes(), false);
|
||||
setLocale('es');
|
||||
assert.equal(classes(), true);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('K-44: Recent Imagery readout keeps its English layout', () => {
|
||||
const source = css('recent-imagery.css');
|
||||
const one = (selector) => {
|
||||
const rules = unguardedRules(source, selector);
|
||||
assert.equal(rules.length, 1, selector);
|
||||
return rules[0];
|
||||
};
|
||||
// The SHOW chip shares its row with the reserved B half.
|
||||
assert.equal(
|
||||
unguardedRules(
|
||||
source,
|
||||
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
unguardedRules(
|
||||
source,
|
||||
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
// Slot rows: their own fixed 44px tag column, no subgrid.
|
||||
const selection = one('.recent-imagery-readout .ri-selection');
|
||||
assert.equal(declaration(selection, 'grid-template-columns'), undefined);
|
||||
assert.equal(declaration(selection, 'column-gap'), undefined);
|
||||
assert.equal(
|
||||
declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'),
|
||||
undefined,
|
||||
);
|
||||
const slot = one('.recent-imagery-readout .ri-slot');
|
||||
assert.equal(
|
||||
declaration(slot, 'grid-template-columns'),
|
||||
'44px minmax(0, 1fr) 22px',
|
||||
);
|
||||
assert.equal(declaration(slot, 'grid-column'), undefined);
|
||||
assert.equal(declaration(slot, 'height'), '20px');
|
||||
assert.equal(
|
||||
declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'),
|
||||
undefined,
|
||||
);
|
||||
// Opacity / SWAP / export row: fixed English columns.
|
||||
assert.equal(
|
||||
declaration(
|
||||
one('.recent-imagery-readout .ri-controls'),
|
||||
'grid-template-columns',
|
||||
),
|
||||
'76px minmax(0, 1fr) 40px 128px',
|
||||
);
|
||||
const label = one('.recent-imagery-readout .ri-opacity-label');
|
||||
assert.equal(declaration(label, 'flex-wrap'), undefined);
|
||||
assert.equal(declaration(label, 'gap'), '4px');
|
||||
assert.equal(declaration(label, 'line-height'), undefined);
|
||||
const swap = one('.recent-imagery-readout .ri-swap');
|
||||
assert.equal(declaration(swap, 'width'), '40px');
|
||||
assert.equal(declaration(swap, 'min-width'), undefined);
|
||||
});
|
||||
+83
-43
@@ -250,12 +250,8 @@
|
||||
gap: 5px;
|
||||
}
|
||||
.radio-transport button {
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 6px 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #8feaf2;
|
||||
@@ -264,6 +260,13 @@
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* A longer translated action stays on one line, cut with an ellipsis. */
|
||||
html:not([lang='en']) .radio-transport button {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.radio-transport button:hover:not(:disabled),
|
||||
.radio-transport button:focus-visible:not(:disabled) {
|
||||
border-color: #65f7ff;
|
||||
@@ -614,14 +617,10 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The name takes the room the status column leaves; a long word wraps inside
|
||||
it instead of pushing the toggle out of line. */
|
||||
.data-toggle-left {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-icon {
|
||||
@@ -629,37 +628,16 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* A fixed icon box, so every name (and the meta line under it, 26px in)
|
||||
starts at the same x whatever the glyph's width. */
|
||||
.data-toggle-left .data-icon {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-name {
|
||||
min-width: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Translated layer names are longer: break between words, and hyphenate a
|
||||
word that still does not fit instead of splitting it anywhere. */
|
||||
html:not([lang='en']) .data-name {
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
|
||||
at least four letters stay on each side. */
|
||||
hyphenate-limit-chars: 8 4 4;
|
||||
}
|
||||
|
||||
.data-toggle-right {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -669,34 +647,68 @@ html:not([lang='en']) .data-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Longer translations below; English keeps the layout above. */
|
||||
|
||||
/* The name takes the room the status column leaves; a long word wraps inside
|
||||
it instead of pushing the toggle out of line. */
|
||||
html:not([lang='en']) .data-toggle-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A fixed icon box, so every name (and the meta line under it, 26px in)
|
||||
starts at the same x whatever the glyph's width. */
|
||||
html:not([lang='en']) .data-toggle-left .data-icon {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Translated layer names are longer: break between words, and hyphenate a
|
||||
word that still does not fit instead of splitting it anywhere. */
|
||||
html:not([lang='en']) .data-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
|
||||
at least four letters stay on each side. */
|
||||
hyphenate-limit-chars: 8 4 4;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .data-count {
|
||||
min-width: auto;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
||||
the space before a " · " separator (see setRowText in layerPanel.js). */
|
||||
.data-keep {
|
||||
html:not([lang='en']) .data-keep {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
|
||||
name instead of squeezing it beside the toggle. */
|
||||
.data-toggle-row--count-line .data-toggle-top {
|
||||
name instead of squeezing it beside the toggle. layerPanel.js sets the
|
||||
class only outside English. */
|
||||
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.data-toggle-row--count-line .data-toggle-right {
|
||||
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
|
||||
toggle onto the count's line. */
|
||||
.data-toggle-row--count-line .data-toggle-left {
|
||||
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.data-toggle-row--count-line .data-count {
|
||||
html:not([lang='en']) .data-toggle-row--count-line .data-count {
|
||||
order: 1;
|
||||
flex: 1 0 100%;
|
||||
padding-left: 26px;
|
||||
@@ -706,7 +718,8 @@ html:not([lang='en']) .data-name {
|
||||
|
||||
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
|
||||
room it needs. */
|
||||
.data-toggle-right:has(
|
||||
html:not([lang='en'])
|
||||
.data-toggle-right:has(
|
||||
.data-toggle-btn:is(
|
||||
.transitioning,
|
||||
.feed-loading,
|
||||
@@ -739,9 +752,12 @@ html:not([lang='en']) .data-name {
|
||||
min-width var(--transition-fast),
|
||||
letter-spacing var(--transition-fast);
|
||||
outline: none;
|
||||
flex: none;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .data-toggle-btn {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -751,18 +767,30 @@ html:not([lang='en']) .data-name {
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* Status chips up to eight characters share one width; a longer word grows
|
||||
its own chip rather than widening every chip and squeezing every name. */
|
||||
.data-toggle-btn.feed-loading,
|
||||
.data-toggle-btn.feed-degraded,
|
||||
.data-toggle-btn.feed-stale,
|
||||
.data-toggle-btn.feed-partial,
|
||||
.data-toggle-btn.feed-fallback,
|
||||
.data-toggle-btn.feed-unavailable {
|
||||
min-width: 72px;
|
||||
min-width: 82px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
/* Status chips up to eight characters share one width; a longer word grows
|
||||
its own chip rather than widening every chip and squeezing every name. */
|
||||
html:not([lang='en'])
|
||||
.data-toggle-btn:is(
|
||||
.feed-loading,
|
||||
.feed-degraded,
|
||||
.feed-stale,
|
||||
.feed-partial,
|
||||
.feed-fallback,
|
||||
.feed-unavailable
|
||||
) {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.data-toggle-btn:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
@@ -964,15 +992,27 @@ html:not([lang='en']) .data-name {
|
||||
.data-row-list-lead {
|
||||
min-width: 34px;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* The whole instruction wraps under itself rather than losing its tail:
|
||||
"Turn right onto …" says nothing, and Russian or Spanish run longer. */
|
||||
.data-row-list-text {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A translated instruction wraps under itself rather than losing its tail:
|
||||
"Gira a la derecha hacia …" says nothing. */
|
||||
html:not([lang='en']) .data-row-list-lead {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
html:not([lang='en']) .data-row-list-text {
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@@ -293,34 +293,32 @@
|
||||
height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Outside A / B there is one chip (SHOW): it takes the whole row, so a long
|
||||
translation is not cut to a stub. */
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
/* Outside A / B there is one chip (SHOW): in a translation it takes the
|
||||
whole row, so a long label is not cut to a stub. English keeps the
|
||||
reserved B half. */
|
||||
html:not([lang='en'])
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
.ri-card-chips
|
||||
.ri-chip[data-slot='a'] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
html:not([lang='en'])
|
||||
.recent-imagery-readout:not([data-mode='ab'])
|
||||
.ri-card-chips
|
||||
.ri-chip[data-slot='b'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4. The selection bar: mode, then two rows that are always there. The slot
|
||||
rows share the bar's columns (subgrid), so the tag column fits the longest
|
||||
tag in either row and both rows stay aligned. */
|
||||
/* 4. The selection bar: mode, then two rows that are always there. */
|
||||
.recent-imagery-readout .ri-selection {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
|
||||
grid-template-rows: 20px 20px 20px;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--weather-card-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.recent-imagery-readout .ri-mode {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
@@ -329,16 +327,14 @@
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 20px;
|
||||
}
|
||||
.recent-imagery-readout .ri-slot-tag {
|
||||
height: 16px;
|
||||
padding: 0 3px;
|
||||
line-height: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
@@ -366,12 +362,26 @@
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
/* A translated slot line (date · source · resolution · state) is longer:
|
||||
/* In a translation the slot rows share the bar's columns (subgrid), so the
|
||||
tag column fits the longest tag in either row and both rows stay aligned.
|
||||
A translated slot line (date · source · resolution · state) is longer:
|
||||
let it take a second line rather than cut off the state at the end. */
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-selection {
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
|
||||
column-gap: 6px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-mode {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-slot {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
height: auto;
|
||||
min-height: 20px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-slot-tag {
|
||||
padding: 0 3px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -383,28 +393,35 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
|
||||
}
|
||||
|
||||
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
|
||||
while no swipe is live. A label longer than its 76px column widens it to
|
||||
the label's longest word, and the value wraps under the label. */
|
||||
while no swipe is live. */
|
||||
.recent-imagery-readout .ri-controls {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
|
||||
128px;
|
||||
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
height: 22px;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-content: center;
|
||||
column-gap: 4px;
|
||||
gap: 4px;
|
||||
font-size: 8px;
|
||||
line-height: 10px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* In a translation a label longer than its 76px column widens it to the
|
||||
label's longest word, and the value wraps under the label. */
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-controls {
|
||||
grid-template-columns:
|
||||
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
|
||||
128px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
row-gap: 0;
|
||||
line-height: 10px;
|
||||
}
|
||||
.recent-imagery-readout .ri-opacity-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
@@ -419,9 +436,13 @@ html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.recent-imagery-readout .ri-swap {
|
||||
min-width: 40px;
|
||||
width: 40px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
html:not([lang='en']) .recent-imagery-readout .ri-swap {
|
||||
width: auto;
|
||||
min-width: 40px;
|
||||
}
|
||||
.recent-imagery-readout .ri-exports .rail-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -160,16 +160,19 @@
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #scene-select {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A translated action longer than its button wraps inside the button instead
|
||||
of running past it. English labels fit on one line, so the English layout
|
||||
is unchanged. */
|
||||
#scene-panel .scene-controls .scene-btn {
|
||||
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
|
||||
included). */
|
||||
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer
|
||||
than the English one; English keeps its 220px. */
|
||||
html:not([lang='en']) #global-loading-detail {
|
||||
max-width: min(300px, 46vw);
|
||||
}
|
||||
#global-loading-status:is(
|
||||
[data-state='complete'],
|
||||
[data-state='cancelled'],
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.gev-voice-card-chips {
|
||||
@@ -271,6 +272,8 @@
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(223, 245, 250, 0.82);
|
||||
/* A whole error line or a long headline wraps inside the card. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.gev-voice-card-referents {
|
||||
|
||||
@@ -123,9 +123,10 @@ html:not([lang='en']) #command-dock .gev-voice-cost-value {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Live hints ("Ask or command", "Requesting microphone") run longer in other
|
||||
languages; give them a second line from the spare space under the readout
|
||||
rather than cutting them mid-word. */
|
||||
/* Live hints ("Ask or command", "Requesting microphone") and the tier switch
|
||||
("СТАНДАРТ — со следующей сессии") run longer in other languages; give them
|
||||
a second line from the spare space under the readout, without the tracking
|
||||
that would push the tier line onto a third, rather than cutting them. */
|
||||
html:not([lang='en'])
|
||||
#command-dock
|
||||
#gev-voice-control:is(
|
||||
@@ -139,6 +140,7 @@ html:not([lang='en'])
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
+21
-10
@@ -147,12 +147,18 @@
|
||||
.rail-card-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
/* One line stays reserved so the card does not jump when a status appears;
|
||||
a long (or translated) status wraps instead of being cut. */
|
||||
.weather-card [data-line-id='status'] {
|
||||
min-height: 1.45em;
|
||||
height: 1.45em;
|
||||
overflow-y: auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
/* One line stays reserved so the card does not jump when a status appears;
|
||||
a translated status runs longer and wraps instead of being cut. */
|
||||
html:not([lang='en']) .weather-card [data-line-id='status'] {
|
||||
height: auto;
|
||||
min-height: 1.45em;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.rail-card-ramp {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
@@ -259,10 +265,12 @@ body.recording-mode .gev-wind-inspection-marker {
|
||||
top: 8px;
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font: 9px/1.45 var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
html:not([lang='en']) .rail-card-zero::after {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-observed-group {
|
||||
border: 1px solid var(--weather-card-border);
|
||||
@@ -313,21 +321,24 @@ body.recording-mode .gev-wind-inspection-marker {
|
||||
.rail-card-compact.status {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rail-card-setting {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* One label column per card, as wide as its longest label: chips line up
|
||||
across rows, and a long translated label never runs under its chips. */
|
||||
.rail-card-settings {
|
||||
html:not([lang='en']) .rail-card-settings {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
|
||||
column-gap: 6px;
|
||||
}
|
||||
.rail-card-setting {
|
||||
display: grid;
|
||||
html:not([lang='en']) .rail-card-setting {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
|
||||
grid-template-columns: subgrid;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rail-card-setting-label {
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
</div>
|
||||
<div class="cyber-panel-body" data-rail-scroller>
|
||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
||||
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
|
||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Vector tile and weather image failures reach the DATA LAYERS row and the
|
||||
// ALPR retry chip in the UI language; the sources keep them in English, and
|
||||
// English shows them as before.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createVectorTileSource } from '../sources/vectorTiles.js';
|
||||
|
||||
globalThis.document ??= {
|
||||
getElementById: () => null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
hidden: false,
|
||||
};
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const { localizeLoadingPresentation } = await import('./shellFeedback.js');
|
||||
|
||||
const row = (id, error) =>
|
||||
LayerPanel.prototype._buildMetaText({
|
||||
id,
|
||||
enabled: true,
|
||||
stats: { status: 'unavailable', error, source: 'OpenSky' },
|
||||
});
|
||||
const chipLabel = (label) =>
|
||||
localizeLoadingPresentation({
|
||||
state: 'retry',
|
||||
label,
|
||||
detail: 'ALPR cameras · retrying in 53s',
|
||||
}).label;
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('the vector tile source fails with the catalog key', async () => {
|
||||
const source = createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.example/index.json',
|
||||
decode: () => [],
|
||||
fetchImpl: async () => new Response('', { status: 503 }),
|
||||
});
|
||||
await assert.rejects(source.getMetadata(), {
|
||||
message: 'Vector tile metadata unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('en: tile and weather image failures read as before', () => {
|
||||
setLocale('en');
|
||||
assert.match(
|
||||
row('alpr-cameras', 'Vector tile metadata unavailable'),
|
||||
/ · Vector tile metadata unavailable$/,
|
||||
);
|
||||
assert.match(
|
||||
row('weather-radar', 'Weather image HTTP 404'),
|
||||
/Weather image HTTP 404$/,
|
||||
);
|
||||
assert.equal(
|
||||
chipLabel('VECTOR TILE METADATA UNAVAILABLE'),
|
||||
'VECTOR TILE METADATA UNAVAILABLE',
|
||||
);
|
||||
assert.equal(
|
||||
chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'),
|
||||
'VECTOR TILES UNAVAILABLE (HTTP 503)',
|
||||
);
|
||||
assert.equal(chipLabel('SOMETHING ELSE'), 'SOMETHING ELSE');
|
||||
});
|
||||
|
||||
const EXPECTED = {
|
||||
ru: {
|
||||
metadata: 'Метаданные векторных тайлов недоступны',
|
||||
http: 'Векторные тайлы недоступны (HTTP 503)',
|
||||
weather: 'Снимок погоды: HTTP 404',
|
||||
},
|
||||
es: {
|
||||
metadata: 'Metadatos de teselas vectoriales no disponibles',
|
||||
http: 'Teselas vectoriales no disponibles (HTTP 503)',
|
||||
weather: 'Imagen del clima: HTTP 404',
|
||||
},
|
||||
};
|
||||
|
||||
for (const [locale, expected] of Object.entries(EXPECTED)) {
|
||||
test(`${locale}: tile failures are translated in the row and the chip`, () => {
|
||||
setLocale(locale);
|
||||
assert.match(
|
||||
row('alpr-cameras', 'Vector tile metadata unavailable'),
|
||||
new RegExp(` · ${expected.metadata}$`),
|
||||
);
|
||||
assert.ok(
|
||||
row('alpr-cameras', 'Vector tiles unavailable (HTTP 503)').endsWith(
|
||||
expected.http,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
chipLabel('VECTOR TILE METADATA UNAVAILABLE'),
|
||||
expected.metadata.toLocaleUpperCase(locale),
|
||||
);
|
||||
assert.equal(
|
||||
chipLabel('VECTOR TILES UNAVAILABLE (HTTP 503)'),
|
||||
expected.http.toLocaleUpperCase(locale),
|
||||
);
|
||||
});
|
||||
|
||||
test(`${locale}: a weather image failure is not read as a source name`, () => {
|
||||
setLocale(locale);
|
||||
assert.ok(
|
||||
row('weather-radar', 'Weather image HTTP 404').endsWith(expected.weather),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// K-38: refusals and failures that source modules produce in English reach
|
||||
// the page translated — through a placeholder at the point of display, or
|
||||
// through a catalog entry when the text is stored as data.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t, translateKnown } from '../i18n/index.js';
|
||||
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
|
||||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||
import { createWeatherSource } from '../layers/weather/source.js';
|
||||
import {
|
||||
presentRoutingFailure,
|
||||
presentRoutingRefusal,
|
||||
} from '../layers/directions/index.js';
|
||||
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
|
||||
|
||||
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
|
||||
|
||||
function snapshot(error) {
|
||||
return {
|
||||
enabled: true,
|
||||
keyRequired: false,
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
providers: [providerSnapshot()],
|
||||
coverage: { loading: false, count: 0, hint: '', error: null },
|
||||
legend: [],
|
||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
||||
street: { open: false, loading: false, error, providerId: null },
|
||||
groundClickReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`K-38 (${locale}): the nearest-image refusal is built in the language and kept by the panel`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
// What streetLevel/index.js openNearest writes into street.error.
|
||||
const refusal = t(
|
||||
'No street-level imagery within {radius} m of that point',
|
||||
{ radius: 50 },
|
||||
);
|
||||
assert.notEqual(refusal, NO_IMAGERY, 'should be translated');
|
||||
assert.match(refusal, /50/, 'keeps the radius');
|
||||
assert.equal(presentStreetLevelPanel(snapshot(refusal)).error, refusal);
|
||||
// The screen-centre refusal is stored as English data and translated
|
||||
// by the panel.
|
||||
const aim = presentStreetLevelPanel(snapshot(AIM_AT_GROUND_MESSAGE));
|
||||
assert.notEqual(aim.error, AIM_AT_GROUND_MESSAGE, 'should be translated');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test(`K-38 (${locale}): the route proxy's refusals are shown translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const shown = t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal('route leg too long'),
|
||||
});
|
||||
assert.doesNotMatch(shown, /route leg too long/);
|
||||
const limited = presentRoutingFailure(
|
||||
Object.assign(new Error('rate limited — try again in a moment'), {
|
||||
status: 429,
|
||||
refusal: 'rate limited',
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(limited, /rate limited|try again in a moment/);
|
||||
const http = presentRoutingFailure(
|
||||
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
|
||||
assert.match(http, /503/);
|
||||
// A text the proxy never sends is shown as given.
|
||||
assert.equal(presentRoutingRefusal('something else'), 'something else');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test(`K-38 (${locale}): a weather manifest timeout is shown translated`, async () => {
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: (_url, { signal }) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
),
|
||||
timeoutMs: 10,
|
||||
});
|
||||
let message = null;
|
||||
try {
|
||||
await source.getSnapshot({ product: 'radar' });
|
||||
} catch (error) {
|
||||
message = error.message;
|
||||
}
|
||||
assert.equal(message, 'Weather request timed out', 'stored in English');
|
||||
setLocale(locale);
|
||||
try {
|
||||
// The layer shows `t(problem)` (weather/index.js presentWeatherProblem).
|
||||
assert.notEqual(t(message), message, 'should be translated');
|
||||
assert.notEqual(translateKnown(message), message);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatUtcDayMonth, t } from '../i18n/index.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { createRailTimeline } from './railTimeline.js';
|
||||
import { createRailTimeline, utcClock } from './railTimeline.js';
|
||||
|
||||
// Remounting controls must not reopen a panel the user already collapsed.
|
||||
const appearedDocuments = new WeakSet();
|
||||
@@ -15,8 +15,9 @@ const ORDER = [
|
||||
const OBSERVED = new Set(ORDER.slice(2));
|
||||
const utc = (time) =>
|
||||
Number.isFinite(Date.parse(time))
|
||||
? `${formatUtcDayMonth(time)} ${new Date(time).toISOString().slice(11, 16)} UTC`
|
||||
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
|
||||
: t('Unavailable');
|
||||
const clockTime = (time) => utcClock(time) || t('Unavailable');
|
||||
const age = (time) => {
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
@@ -31,7 +32,7 @@ const age = (time) => {
|
||||
};
|
||||
const historyTime = (time) =>
|
||||
t('{time} · {minutes} min ago', {
|
||||
time: utc(time).slice(6),
|
||||
time: clockTime(time),
|
||||
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
|
||||
});
|
||||
const dated = (time) => `${utc(time)} · ${age(time)}`;
|
||||
@@ -172,7 +173,7 @@ export function createWeatherPanel({
|
||||
const shown = product?.shown ?? summary.shownTime;
|
||||
if (state.mode === 'history' && product?.selected === null) {
|
||||
const gap = summary.maxGapMinutes || 30;
|
||||
const time = utc(state.target).slice(6);
|
||||
const time = clockTime(state.target);
|
||||
detail =
|
||||
gap < 60
|
||||
? t('No frame within {gap} min of {time}', { gap, time })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
|
||||
@@ -654,3 +655,34 @@ test('a change of open card scrolls its header into view once; refreshes never m
|
||||
assert.deepEqual(intoView, [], 'ancestor scrollers are never moved');
|
||||
view.destroy();
|
||||
});
|
||||
test('history time keeps the hour whatever the length of the localized date (K-6)', () => {
|
||||
for (const locale of ['en', 'es', 'ru']) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const f = fixture();
|
||||
const view = createWeatherPanel(f);
|
||||
view.update([wind, radar]);
|
||||
const ends = f.find((n) => n.className === 'rail-timeline-endpoints');
|
||||
assert.deepEqual(
|
||||
ends.children.map((n) => n.textContent),
|
||||
['01:00 UTC', '01:05 UTC'],
|
||||
locale,
|
||||
);
|
||||
f.state({ mode: 'history', target: ticks[1] });
|
||||
assert.match(
|
||||
line(f, radar.id, 'time').textContent,
|
||||
/(^|[^\d:])01:05 UTC/,
|
||||
locale,
|
||||
);
|
||||
f.state({ products: [{ id: radar.id, shown: null, selected: null }] });
|
||||
assert.match(
|
||||
line(f, radar.id, 'time').textContent,
|
||||
/(^|[^\d:])01:05 UTC/,
|
||||
locale,
|
||||
);
|
||||
view.destroy();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user