diff --git a/docs/I18N.md b/docs/I18N.md
index ac099f0c6..05c6a5a09 100644
--- a/docs/I18N.md
+++ b/docs/I18N.md
@@ -43,7 +43,7 @@ label.textContent = t(FEED_STATE_LABELS[state]);
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language.
-- **Identical English means identical translation.** If two places need different words, make the English differ.
+- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
## After you add or change text
diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs
index 30e339ca8..622f13df6 100644
--- a/scripts/i18n.mjs
+++ b/scripts/i18n.mjs
@@ -17,7 +17,7 @@ const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
- String.raw`\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
+ String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
'g',
);
@@ -100,11 +100,13 @@ export function extractMessages() {
for (const match of source.matchAll(CALL)) {
const line = source.slice(0, match.index).split('\n').length;
const where = `${file}:${line}`;
- if (match[2]) {
- if (match[2][0] === '`' && match[2].includes('${')) continue;
- add(unquote(match[2]), where);
+ if (match[1]) {
+ add(`${unquote(match[2])}|${unquote(match[1])}`, where);
+ } else if (match[4]) {
+ if (match[4][0] === '`' && match[4].includes('${')) continue;
+ add(unquote(match[4]), where);
} else {
- add(unquote(match[4]), where, { one: unquote(match[3]) });
+ add(unquote(match[6]), where, { one: unquote(match[5]) });
}
}
}
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 2f8e042f0..365882864 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -455,7 +455,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": []
},
@@ -502,7 +503,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": []
},
@@ -556,7 +558,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -569,7 +572,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": []
},
@@ -712,7 +716,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -822,7 +827,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -880,7 +886,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -1007,7 +1014,9 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js",
+ "src/layers/recentImagery/display.js"
],
"external": ["cesium"]
},
@@ -2322,7 +2331,9 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js",
+ "src/layers/recentImagery/display.js"
],
"external": [
"hls.js",
@@ -2848,7 +2859,9 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js",
+ "src/layers/recentImagery/display.js"
],
"external": [
"hls.js",
@@ -3092,7 +3105,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
- "src/i18n/locales/ru.json"
+ "src/i18n/locales/ru.json",
+ "src/ui/unitLabels.js"
],
"external": ["cesium"]
},
diff --git a/src/cockpitMath.js b/src/cockpitMath.js
index a0bd83676..04ff89417 100644
--- a/src/cockpitMath.js
+++ b/src/cockpitMath.js
@@ -83,11 +83,32 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
-/** Format the cockpit Context scope without overstating installation coverage. */
+/**
+ * The English Context scope line. The cockpit passes its own formatter so the
+ * page shows it in the active language; this module stays i18n-free.
+ */
+const ENGLISH_CONTEXT_SCOPE = Object.freeze({
+ window: ({ label, radiusKm }) =>
+ `${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
+ installations: ({ scope, coverage }) =>
+ `${scope} · INSTALLATIONS ${coverage}`,
+});
+
+/**
+ * Format the cockpit Context scope without overstating installation coverage.
+ * @param {string|number} subjectLabel
+ * @param {number} radiusM
+ * @param {string|null} [installationCoverage]
+ * @param {{window: Function, installations: Function}} [format] Builds the
+ * line from `{ label, radiusKm }` (radiusKm is null when unknown) and
+ * `{ scope, coverage }`.
+ * @returns {string}
+ */
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
+ format = ENGLISH_CONTEXT_SCOPE,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
@@ -97,12 +118,12 @@ export function formatCockpitContextScope(
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
- ? String(Math.round(radiusM / 1000))
- : '—';
+ ? Math.round(radiusM / 1000)
+ : null;
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
- const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
- return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
+ const base = format.window({ label: normalizedLabel, radiusKm });
+ return coverage ? format.installations({ scope: base, coverage }) : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
diff --git a/src/data/bhoteKoshiEmbeddedMedia.js b/src/data/bhoteKoshiEmbeddedMedia.js
index 2162e5a42..294991d2d 100644
--- a/src/data/bhoteKoshiEmbeddedMedia.js
+++ b/src/data/bhoteKoshiEmbeddedMedia.js
@@ -40,6 +40,57 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
+// Authored English in public/events/bhote-koshi-2026/event.json. The pack is
+// data and stays English; these known strings translate at display. Anything
+// else (a newer pack, a credit, a source's own title) shows as authored. A
+// string that is not known whole translates by its " · " parts.
+const EVENT_PACK_TEXT = new Set([
+ // Field reports: our labels (credits after " · " stay) and their statuses.
+ msg('Rasuwagadhi witness video'),
+ msg('Bhote Koshi valley footage'),
+ msg('Flood footage'),
+ msg('Compilation and casualty claims'),
+ msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
+ msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
+ msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
+ msg('SOURCE GEOCONFIRMED'),
+ // Reconstruction caveat.
+ msg(
+ '2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
+ ),
+ // Evidence titles that are more than a place name.
+ msg('Immediate collapse viewpoint'),
+ msg('Collapse viewpoint'),
+ msg('Debris-dammed lake'),
+ msg('Dammed lake'),
+ msg('Second landslide'),
+ msg('Gyirong border gate'),
+ msg('Border gate'),
+ msg('Timure evidence cluster'),
+ msg('Mailung / Upper Trishuli bridge'),
+ msg('Dandagaun viewpoint restaurant'),
+ msg('Dandagaun viewpoint'),
+ msg('Bidur - Trishuli bridge'),
+ msg('Bidur bridge'),
+ msg('Devighat / Taadi Khola Bridge'),
+ msg('Devighat bridge'),
+]);
+
+/**
+ * Display text for an authored event-pack string.
+ * @param {unknown} value
+ * @returns {string}
+ */
+export function bhoteEventText(value) {
+ const text = value == null ? '' : String(value);
+ if (EVENT_PACK_TEXT.has(text)) return t(text);
+ if (!text.includes(' · ')) return text;
+ return text
+ .split(' · ')
+ .map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
+ .join(' · ');
+}
+
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -991,7 +1042,9 @@ export function createBhoteKoshiEmbeddedMedia({
'aria-label',
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
- source: observation?.title || t('witness source'),
+ source: observation?.title
+ ? bhoteEventText(observation.title)
+ : t('witness source'),
}),
);
const leader = documentRef.createElementNS(
@@ -1006,7 +1059,7 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
- title.textContent = String(
+ title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
@@ -1060,7 +1113,9 @@ export function createBhoteKoshiEmbeddedMedia({
}
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
- source: observation?.title || t('witness source'),
+ source: observation?.title
+ ? bhoteEventText(observation.title)
+ : t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js
index 1452bc57b..4bf7fe7b3 100644
--- a/src/data/bhoteKoshiEvent.js
+++ b/src/data/bhoteKoshiEvent.js
@@ -19,12 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
+ bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
-import { t, tn, msg } from '../i18n/index.js';
+import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -404,6 +405,58 @@ function evidencePhaseLabel(phase) {
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
+// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
+// 16 OCT 2021"); unknown names and unparseable dates show as authored.
+const IMAGERY_LABEL_NAMES = Object.freeze([
+ [/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
+ [/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
+]);
+
+/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
+function imageryDateText(observedAt) {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
+ if (!match) return '';
+ const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
+ if (Number.isNaN(date.getTime())) return '';
+ const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
+ return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
+}
+
+/**
+ * Localized name and date of an imagery endpoint for display.
+ * @param {{label?: string, observedAt?: string}} entry
+ * @returns {{name: string, date: string}}
+ */
+export function imageryEndpointLabel(entry) {
+ const label = String(entry?.label || '');
+ const split = label.lastIndexOf(' · ');
+ const authoredName = split >= 0 ? label.slice(0, split) : label;
+ const authoredDate = split >= 0 ? label.slice(split + 3) : '';
+ let name = authoredName;
+ for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
+ const match = pattern.exec(authoredName);
+ if (match) name = t(message, { year: match[1] });
+ }
+ return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
+}
+
+function imageryEndpointText(entry) {
+ const { name, date } = imageryEndpointLabel(entry);
+ return [name, date].filter(Boolean).join(' · ');
+}
+
+/** Name, then the date kept on one line so the year never wraps alone. */
+function renderImageryEndpoint(element, entry) {
+ if (!element) return;
+ const { name, date } = imageryEndpointLabel(entry);
+ element.textContent = date ? `${name} · ` : name;
+ if (!date) return;
+ const dateElement = document.createElement('span');
+ dateElement.className = 'bhote-event-date';
+ dateElement.textContent = date;
+ element.appendChild(dateElement);
+}
+
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
@@ -730,7 +783,7 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
- observation.shortTitle || observation.title,
+ bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
@@ -916,10 +969,10 @@ function createPanel(event, handlers) {
`;
const refs = collectPanelReferences(panel);
- refs.beforeDate.textContent = event.imagery.before.label;
- refs.afterDate.textContent = event.imagery.after.label;
+ renderImageryEndpoint(refs.beforeDate, event.imagery.before);
+ renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
- refs.caveat.textContent = event.reconstruction.caveat;
+ refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -927,8 +980,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
- label.textContent = report.label;
- status.textContent = report.status;
+ label.textContent = bhoteEventText(report.label);
+ status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -2362,7 +2415,7 @@ export function createBhoteKoshiEventLayer({
interactive: true,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
- { source: observation.title },
+ { source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
@@ -2396,7 +2449,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
- title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
+ title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2602,8 +2655,8 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
- beforeTitle: event.imagery.before.label,
- afterTitle: event.imagery.after.label,
+ beforeTitle: imageryEndpointText(event.imagery.before),
+ afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
t(
@@ -3058,7 +3111,10 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playSceneButton,
'aria-label',
t('Continue {scene} from next shot', {
- scene: _sceneContext?.sceneTitle || t('current scene'),
+ scene:
+ _sceneContext?.sceneDisplayTitle ||
+ _sceneContext?.sceneTitle ||
+ t('current scene'),
}),
);
setElementProperty(
@@ -3095,8 +3151,12 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
- ? _sceneContext?.shotTitle || t('SCENE SHOT')
- : observation?.shortTitle || observation?.title || t('EVIDENCE'),
+ ? _sceneContext?.shotDisplayTitle ||
+ _sceneContext?.shotTitle ||
+ t('SCENE SHOT')
+ : observation?.shortTitle || observation?.title
+ ? bhoteEventText(observation.shortTitle || observation.title)
+ : t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
@@ -3120,7 +3180,9 @@ export function createBhoteKoshiEventLayer({
sceneDirected
? t('{scene} / AUTHORED SHOT', {
scene: String(
- _sceneContext?.sceneTitle || t('NEPAL FLOOD INCIDENT'),
+ _sceneContext?.sceneDisplayTitle ||
+ _sceneContext?.sceneTitle ||
+ t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
@@ -3150,7 +3212,10 @@ export function createBhoteKoshiEventLayer({
'aria-label',
sceneDirected
? t('Play {shot}', {
- shot: _sceneContext?.shotTitle || t('current scene shot'),
+ shot:
+ _sceneContext?.shotDisplayTitle ||
+ _sceneContext?.shotTitle ||
+ t('current scene shot'),
})
: t('Play reconstruction'),
);
@@ -3211,7 +3276,7 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
- ? _event?.reconstruction?.caveat || ''
+ ? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs
index 9dc015f9f..785cf87ef 100644
--- a/src/data/directions.test.mjs
+++ b/src/data/directions.test.mjs
@@ -27,6 +27,13 @@ import {
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
+import { instructionFor } from './routeSteps.js';
+import { setLocale } from '../i18n/index.js';
+import {
+ directionsInstruction,
+ routeDistanceText,
+ routeDurationText,
+} from '../layers/directions/index.js';
import {
claimPointer,
isPointerFree,
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
lastUpdate: 5,
});
assert.equal(ready.count, 3);
- assert.equal(ready.coverage, '21 km · 25 min · Drive');
+ assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
assert.equal(
ready.loadingLabel,
- '21 km · 25 min · Drive',
+ '21\u00a0km · 25\u00a0min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
- 'Step 2 of 3 · then 240 m · 39 s',
+ 'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
);
assert.deepEqual(
list.items.map((item) => item.lead),
- ['8 m', '240 m', '0 m'],
+ ['8\u00a0m', '240\u00a0m', '0\u00a0m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
@@ -773,3 +780,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
+
+test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
+ const types = [
+ 'depart',
+ 'arrive',
+ 'turn',
+ 'new name',
+ 'continue',
+ 'end of road',
+ 'fork',
+ 'merge',
+ 'on ramp',
+ 'off ramp',
+ 'roundabout',
+ 'rotary',
+ 'roundabout turn',
+ 'exit roundabout',
+ 'exit rotary',
+ 'use lane',
+ 'notification',
+ 'something new',
+ ];
+ const modifiers = [
+ null,
+ 'left',
+ 'right',
+ 'slight left',
+ 'slight right',
+ 'sharp left',
+ 'sharp right',
+ 'straight',
+ 'uturn',
+ ];
+ const roads = [
+ { name: '', ref: null },
+ { name: 'Congress Avenue', ref: null },
+ { name: 'Mannerheimintie', ref: 'E12' },
+ { name: '', ref: 'I 35' },
+ ];
+ for (const type of types)
+ for (const modifier of modifiers)
+ for (const road of roads)
+ for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
+ const step = { type, modifier, exit, ...road };
+ assert.equal(
+ directionsInstruction(step),
+ instructionFor(step),
+ JSON.stringify(step),
+ );
+ }
+ // An older payload without a maneuver type keeps the proxy's sentence.
+ assert.equal(
+ directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
+ 'Head out on Kaivokatu',
+ );
+});
+
+test('route numbers follow the page language and never break from their unit', (t) => {
+ t.after(() => setLocale('en'));
+ assert.equal(routeDistanceText(8.4), '8\u00a0m');
+ assert.equal(routeDistanceText(236), '240\u00a0m');
+ assert.equal(routeDistanceText(1500), '1.5\u00a0km');
+ assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
+ assert.equal(routeDurationText(39), '39\u00a0s');
+ assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
+ assert.equal(routeDurationText(7200), '2\u00a0h');
+ assert.equal(routeDistanceText(-1), '');
+ assert.equal(routeDurationText(Number.NaN), '');
+
+ setLocale('ru');
+ assert.equal(routeDistanceText(1500), '1,5\u00a0км');
+ assert.equal(routeDurationText(240), '4\u00a0мин');
+ assert.equal(
+ directionsInstruction({
+ type: 'turn',
+ modifier: 'right',
+ name: 'East 7th Street',
+ }),
+ 'Поверните направо на East 7th Street',
+ );
+ assert.equal(
+ directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
+ 'На кольце сверните на 2-й съезд',
+ );
+ const list = directionsStepList({ route });
+ assert.equal(list.items[1].lead, '240\u00a0м');
+});
diff --git a/src/hud.js b/src/hud.js
index 7547563a9..8e779575c 100644
--- a/src/hud.js
+++ b/src/hud.js
@@ -35,7 +35,8 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
-import { t } from './i18n/index.js';
+import { formatNumber, msg, t } from './i18n/index.js';
+import { unitLabel } from './ui/unitLabels.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -61,6 +62,81 @@ const HUD_COLORS = {
},
};
+/**
+ * Mode names shown for each style. Same English as STYLE_STATUS_LABELS in
+ * ui/visualPresets.js, so the top-left mode and the dock's active style
+ * translate alike.
+ */
+const MODE_LABELS = Object.freeze({
+ normal: msg('NORMAL'),
+ retro: msg('CRT'),
+ surveillance: msg('NVG'),
+ thermal: msg('FLIR'),
+ anime: msg('ANIME'),
+ noir: msg('NOIR'),
+ snow: msg('SNOW'),
+});
+
+/** Region ids from _regionLabel() and the name shown for each. */
+const REGION_LABELS = Object.freeze({
+ ARCTIC: msg('ARCTIC'),
+ ANTARCTIC: msg('ANTARCTIC'),
+ 'NORTH AMERICA': msg('NORTH AMERICA'),
+ 'SOUTH AMERICA': msg('SOUTH AMERICA'),
+ EUROPE: msg('EUROPE'),
+ AFRICA: msg('AFRICA'),
+ ASIA: msg('ASIA'),
+ OCEANIA: msg('OCEANIA'),
+ 'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
+ 'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
+});
+
+/** The name shown for a _viewBand() id. */
+function viewBandLabel(band) {
+ switch (band) {
+ case 'STREET':
+ return t('STREET', { context: 'view band' });
+ case 'CITY':
+ return t('CITY', { context: 'view band' });
+ case 'METRO':
+ return t('METRO', { context: 'view band' });
+ case 'REGIONAL':
+ return t('REGIONAL', { context: 'view band' });
+ default:
+ return t('GLOBAL', { context: 'view band' });
+ }
+}
+
+/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
+function hudQuantity(value, unit, digits = 0) {
+ const number = formatNumber(value, {
+ useGrouping: false,
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
+ });
+ return `${number}${unitLabel(unit).toUpperCase()}`;
+}
+
+/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
+function hudCoordinate(value, positive, negative) {
+ return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
+}
+
+/** The summary's locality line in the active language (see hudLocality.js). */
+const LOCALIZED_LOCALITY = Object.freeze({
+ near: ({ poi, city, distKm }) =>
+ t('NEAR {poi} ({city}) {distance}', {
+ poi,
+ city,
+ distance: hudQuantity(Math.round(distKm), 'km'),
+ }),
+ sector: ({ latDeg, lonDeg }) =>
+ t('SECTOR {lat} {lon}', {
+ lat: hudCoordinate(latDeg, t('N'), t('S')),
+ lon: hudCoordinate(lonDeg, t('E'), t('W')),
+ }),
+});
+
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
@@ -201,7 +277,7 @@ export class IntelHUD {
TOP SECRET // SI-TK // NOFORN
${this._missionId} ${this._sensorId}
-
NORMAL
+
${t(MODE_LABELS.normal)}
${t('SUMMARY')}
${t('Awaiting telemetry...')}
@@ -646,7 +722,7 @@ export class IntelHUD {
if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode');
- const modeLabel = modeEl?.textContent || 'NORMAL';
+ const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
const region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
@@ -658,20 +734,41 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
+ // Display only: the AI summary request carries its own context, so this
+ // line is built in the active language.
const altTag =
altDisplayM >= 1000
- ? `${(altDisplayM / 1000).toFixed(1)}KM`
- : `${Math.round(altDisplayM)}M`;
+ ? hudQuantity(altDisplayM / 1000, 'km', 1)
+ : hudQuantity(Math.round(altDisplayM), 'm');
const winTag = window
- ? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
- : 'N/A';
+ ? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
+ : t('N/A');
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
- const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
+ const localityTag = composeLocalityTag(
+ nearest,
+ m.latDeg,
+ m.lonDeg,
+ LOCALIZED_LOCALITY,
+ );
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
);
- const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
+ // ALT, SUN and ONA are sensor codes, as in the corner readouts.
+ const line = t(
+ '{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
+ {
+ mode: modeLabel,
+ band: viewBandLabel(band),
+ locality: localityTag,
+ region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
+ alt: altTag,
+ window: winTag,
+ sun: formatNumber(Math.round(m.sunEl)),
+ ona: formatNumber(Math.round(m.ona)),
+ zone: localTag,
+ },
+ );
return provenance ? `${line} | ${provenance}` : line;
}
@@ -827,8 +924,9 @@ export class IntelHUD {
// Update mode label
const modeEl = document.getElementById('hud-mode');
if (modeEl) {
- const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
- modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
+ modeEl.textContent = MODE_LABELS[styleName]
+ ? t(MODE_LABELS[styleName])
+ : styleName.toUpperCase();
}
// Update color scheme
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
diff --git a/src/hudLocality.js b/src/hudLocality.js
index 2a2dbd3a4..fc4f0714b 100644
--- a/src/hudLocality.js
+++ b/src/hudLocality.js
@@ -23,6 +23,17 @@
*/
export const NEAR_POI_MAX_KM = 150;
+/**
+ * The English locality line. The HUD passes its own formatter so the page
+ * shows it in the active language; this default stays i18n-free.
+ */
+const ENGLISH_LOCALITY = Object.freeze({
+ near: ({ poi, city, distKm }) =>
+ `NEAR ${poi} (${city}) ${Math.round(distKm)}KM`,
+ sector: ({ latDeg, lonDeg }) =>
+ `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
+});
+
/**
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
* @param {number} value Signed decimal degrees.
@@ -42,13 +53,24 @@ function coordinateTag(value, positive, negative) {
* catalogue is empty.
* @param {number} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees.
+ * @param {{near: Function, sector: Function}} [format] Builds the line from
+ * `{ poi, city, distKm }` (names upper-cased) or `{ latDeg, lonDeg }`.
* @returns {string} `NEAR
() KM` within the bound (inclusive),
* otherwise `SECTOR `.
*/
-export function composeLocalityTag(nearest, latDeg, lonDeg) {
+export function composeLocalityTag(
+ nearest,
+ latDeg,
+ lonDeg,
+ format = ENGLISH_LOCALITY,
+) {
const distKm = Number(nearest?.distKm);
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
- return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
+ return format.near({
+ poi: String(nearest.poi).toUpperCase(),
+ city: String(nearest.city).toUpperCase(),
+ distKm,
+ });
}
- return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
+ return format.sector({ latDeg, lonDeg });
}
diff --git a/src/hudLocality.test.mjs b/src/hudLocality.test.mjs
index 25b6618b5..237e1e527 100644
--- a/src/hudLocality.test.mjs
+++ b/src/hudLocality.test.mjs
@@ -17,7 +17,7 @@ test('the NEAR bound is metro scale, not continental', () => {
test('a landmark under the camera reads NEAR', () => {
assert.equal(
- composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
+ composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
);
assert.equal(
@@ -40,20 +40,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
});
test('the boundary is pinned on both sides, inclusive at the bound', () => {
- const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
- assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
+ const at = composeLocalityTag(
+ { ...LINCOLN, distKm: NEAR_POI_MAX_KM },
+ 40,
+ -78,
+ );
+ assert.match(
+ at,
+ /^NEAR LINCOLN MEMORIAL/,
+ 'exactly at the bound still reads NEAR',
+ );
- const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
+ const just_under = composeLocalityTag(
+ { ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
+ 40,
+ -78,
+ );
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
- const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
+ const just_over = composeLocalityTag(
+ { ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
+ 40,
+ -78,
+ );
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
});
test('southern and western hemispheres carry the right suffixes', () => {
// Rio and Honolulu — the two the fallback already handled correctly in the field.
- assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
- assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
+ assert.equal(
+ composeLocalityTag(null, -22.9068, -43.1729),
+ 'SECTOR 22.91S 43.17W',
+ );
+ assert.equal(
+ composeLocalityTag(null, 21.3069, -157.8583),
+ 'SECTOR 21.31N 157.86W',
+ );
});
// The tests above all pass against a hud.js that still computes the tag inline —
@@ -72,7 +94,9 @@ test('hud.js actually composes its summary through this helper', () => {
'hud.js must import composeLocalityTag from ./hudLocality.js',
);
assert.equal(
- has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
+ has(
+ /const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
+ ),
true,
'_composeSummary must build its locality tag through composeLocalityTag()',
);
@@ -91,6 +115,24 @@ test('hud.js actually composes its summary through this helper', () => {
test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
- assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
+ assert.match(
+ composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
+ /^SECTOR /,
+ );
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
});
+
+test('a formatter builds the line from the same parts', () => {
+ const format = {
+ near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
+ sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
+ };
+ assert.equal(
+ composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
+ 'near:ALCATRAZ ISLAND|SAN FRANCISCO|2.4',
+ );
+ assert.equal(
+ composeLocalityTag(null, 21.3, -157.8, format),
+ 'sector:21.3|-157.8',
+ );
+});
diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs
index abe1414fc..5d76567c5 100644
--- a/src/i18n/i18n.test.mjs
+++ b/src/i18n/i18n.test.mjs
@@ -130,6 +130,12 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
+test('t() context separates identical English with different meanings', () => {
+ assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
+ setLocale('ru');
+ assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
+});
+
test('msg() returns its text unchanged', () => {
assert.equal(msg('Live Flights'), 'Live Flights');
});
diff --git a/src/i18n/index.js b/src/i18n/index.js
index 6ccd2eef1..281acfdcd 100644
--- a/src/i18n/index.js
+++ b/src/i18n/index.js
@@ -3,6 +3,7 @@
// t('Save keys') → 'Сохранить ключи'
// t('Cleared {name}', { name }) → named interpolation
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
+// t('CLEAR', { context: 'action' }) → same English, own entry
// msg('Live Flights') → marks a string kept in data
// for a later t(); returns it as is
//
@@ -76,11 +77,13 @@ function lookup(text) {
/**
* Translate English source text into the active locale.
* @param {string} text English text, e.g. 'Hold Space to talk'
- * @param {object} [values] Named interpolation values
+ * @param {object} [values] Named interpolation values. `context` tells
+ * apart identical English with different meanings: t('CLEAR', { context:
+ * 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
* @returns {string}
*/
export function t(text, values) {
- const entry = lookup(text);
+ const entry = lookup(values?.context ? `${values.context}|${text}` : text);
return interpolate(typeof entry === 'string' ? entry : text, values);
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index c625a92bc..d0f1d9401 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -275,13 +275,13 @@
"Expand contact panel": "Expandir el panel de contacto",
"Expand cockpit briefing panel": "Expandir el panel de información de la cabina",
"Expand briefing panel": "Expandir el panel de información",
- "CYCLE ON": "CICLO ACTIVADO",
- "CYCLE OFF": "CICLO DESACTIVADO",
+ "CYCLE ON": "CICLO: SÍ",
+ "CYCLE OFF": "CICLO: NO",
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Detén el cambio automático de páginas. Anterior, Siguiente y las pestañas SIG/NEWS/LOCAL siguen disponibles.",
"REGIONAL NEWS": "NOTICIAS REGIONALES",
"LOCAL INFO": "INFORMACIÓN LOCAL",
"LATEST LOCATION-MATCHED REPORTING": "ÚLTIMOS REPORTES SEGÚN UBICACIÓN",
- "PLACE / CONDITIONS / POSITION": "LUGAR / CONDICIONES / POSICIÓN",
+ "PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIÓN",
"POSITION UNAVAILABLE": "POSICIÓN NO DISPONIBLE",
"REGIONAL NEWS UNAVAILABLE": "NOTICIAS REGIONALES NO DISPONIBLES",
"REGION UNAVAILABLE": "REGIÓN NO DISPONIBLE",
@@ -360,7 +360,7 @@
"CURATED": "CURADA",
"RAW PRIOR": "PREVIA SIN PROCESAR",
"MONITOR": "MONITOR",
- "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}",
+ "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RANGE {range} m · {projection}{calBadge} · {provider}{status}",
"{count} cameras loaded · click a camera to activate": {
"one": "{count} cámara cargada · haz clic en una cámara para activarla",
"many": "{count} de cámaras cargadas · haz clic en una cámara para activarla",
@@ -442,7 +442,7 @@
"SNAPS TO AVAILABLE STATIONS": "SE AJUSTA A EMISORAS DISPONIBLES",
"Radio playback": "Reproducción de Radio",
"Previous filtered station": "Emisora filtrada anterior",
- "PLAY": "REPRODUCIR",
+ "PLAY": "ESCUCHAR",
"Play selected station": "Reproducir la emisora seleccionada",
"Next filtered station": "Siguiente emisora filtrada",
"STOP": "DETENER",
@@ -792,8 +792,6 @@
"City Overload": "Sobrecarga urbana",
"Omniscience Pullback": "Alejamiento omnisciente",
"CONNECTING": "CONECTANDO",
- "LISTENING": "ESCUCHANDO",
- "EXECUTING": "EJECUTANDO",
"ERROR": "ERROR",
"Session ended — cost cap {cost}": "Sesión finalizada — límite de costo {cost}",
"VOICE STANDBY": "VOZ EN ESPERA",
@@ -801,7 +799,7 @@
"VOICE UNAVAILABLE": "VOZ NO DISPONIBLE",
"WebRTC microphone support unavailable": "Compatibilidad con micrófono WebRTC no disponible",
"Requesting microphone": "Solicitando micrófono",
- "Hold Space to talk": "Mantén pulsado Space para hablar",
+ "Hold Space to talk": "Mantén Space y habla",
"Release Space to send": "Suelta Space para enviar",
"Ask or command": "Pregunta o ordena",
"Voice off": "Voz desactivada",
@@ -839,7 +837,7 @@
"Live ships, worldwide": "Buques en vivo, en todo el mundo",
"Live active-fire detections": "Detecciones de incendios activos en vivo",
"Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave usa una simulación)",
- "Bing imagery map stacks + world terrain": "Conjuntos de mapas Bing imagery + terreno mundial",
+ "Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial",
"More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)",
"Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales",
"Scene action failed": "La acción de escena falló",
@@ -847,7 +845,7 @@
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
"Voice model: {pendingId} — click to switch to {target}; applies next session": "Modelo de voz: {pendingId} — haz clic para cambiar a {target}; se aplica en la próxima sesión",
"Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Costo de sesión estimado en {modelId} — {responses} respuesta(s). Avisa a {warn} y cierra la sesión a {cap}.",
- "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Imágenes y cobertura a nivel de calle en la capa A pie de calle. Gratis: registra una app en el panel de desarrolladores de Mapillary y pega su Client Token",
+ "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Imágenes y cobertura a nivel de calle en la capa A pie de calle. Gratis: registra una app en el panel de desarrolladores de Mapillary y pega su token de cliente (Client Token)",
"Voice control — API key or ChatGPT OAuth": "Control de voz — clave de API u OAuth de ChatGPT",
"WITHIN {km} KM": "A MENOS DE {km} KM",
"Syncing flow · Roads: {source}": "Sincronizando flujo · Vías: {source}",
@@ -988,7 +986,7 @@
"{provider} · EMBEDDED SOURCE": "{provider} · FUENTE INSERTADA",
"OPEN ORIGINAL ↗": "ABRIR ORIGINAL ↗",
"{provider} media: {source}": "Contenido de {provider}: {source}",
- "{provider} · PROVIDER CONTROLS": "{provider} · CONTROLES DEL PROVEEDOR",
+ "{provider} · PROVIDER CONTROLS": "{provider} · CONTROLES NATIVOS",
"YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ABRE EL ORIGINAL",
"Facebook player API did not initialize.": "La API del reproductor de Facebook no se inicializó.",
"Facebook player API could not load.": "No se pudo cargar la API del reproductor de Facebook.",
@@ -1002,10 +1000,10 @@
"Preloading {provider} media": "Precargando contenido de {provider}",
"LOADING X POST…": "CARGANDO PUBLICACIÓN DE X…",
"The X post is unavailable or cannot be embedded.": "La publicación de X no está disponible o no se puede insertar.",
- "X POST · PROVIDER CONTROLS": "PUBLICACIÓN DE X · CONTROLES DEL PROVEEDOR",
+ "X POST · PROVIDER CONTROLS": "PUBLICACIÓN DE X · CONTROLES NATIVOS",
"{reason} OPEN ORIGINAL.": "{reason} ABRE EL ORIGINAL.",
"X embed unavailable": "Inserción de X no disponible",
- "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · CONTROLES DEL PROVEEDOR",
+ "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · CONTROLES NATIVOS",
"Facebook embed unavailable": "Inserción de Facebook no disponible",
"AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "REPRODUCCIÓN AUTOMÁTICA BLOQUEADA · USA LOS CONTROLES DEL PROVEEDOR",
"Embedded {provider} media for {source}": "Contenido de {provider} insertado para {source}",
@@ -1519,13 +1517,13 @@
"Mode": "Modo",
"Imagery on Esri · Google 3D returns when cleared": "Imágenes sobre Esri · Google 3D vuelve al borrarlas",
"Imagery opacity": "Opacidad de las imágenes",
- "SWAP": "INTERCAMBIAR",
+ "SWAP": "CAMBIO",
"VS BASEMAP": "VS MAPA BASE",
"Trade the two sides of the divider": "Intercambiar los dos lados del divisor",
"A / B": "A / B",
"Newest low-cloud day for this box · scene cloud, not box cloud": "Día más reciente con pocas nubes para este recuadro · nubes de la escena, no del recuadro",
"Newest day covering this box · cloudier than 20%": "Día más reciente que cubre este recuadro · más de un 20% de nubes",
- "SELECT BOX": "SELECCIONAR RECUADRO",
+ "SELECT BOX": "SELECCIONAR",
"Drag a box on the map (Esc cancels)": "Arrastra un recuadro en el mapa (Esc cancela)",
"Newest day with imagery for this box · partial coverage": "Día más reciente con imágenes para este recuadro · cobertura parcial",
"USE VIEW": "USAR VISTA",
@@ -1559,7 +1557,7 @@
"Source off": "Fuente desactivada",
"Unpin {slot}": "Desfijar {slot}",
"Unpin the image": "Desfijar la imagen",
- "EXPORT {slot}": "EXPORTAR {slot}",
+ "EXPORT {slot}": "EXP. {slot}",
"EXPORT": "EXPORTAR",
"Download {slot} as a PNG": "Descargar {slot} como PNG",
"Download the image as a PNG": "Descargar la imagen como PNG",
@@ -1597,7 +1595,7 @@
"Select a box or use the view": "Selecciona un recuadro o usa la vista",
"Searching the last 30 days": "Buscando en los últimos 30 días",
"No days to show for this box": "No hay días que mostrar para este recuadro",
- "Drag the divider · SWAP trades sides": "Arrastra el divisor · INTERCAMBIAR cambia los lados",
+ "Drag the divider · SWAP trades sides": "Arrastra el divisor · CAMBIO invierte los lados",
"Export failed · {error}": "La exportación falló · {error}",
"← → preview · A or B pins the focused day": "← → vista previa · A o B fija el día enfocado",
"← → preview the other side · A or B pins it": "← → vista previa del otro lado · A o B lo fija",
@@ -1801,5 +1799,217 @@
"Referenced items": "Elementos citados",
"NOTES & SOURCES": "NOTAS Y FUENTES",
"ΔN": "ΔN",
- "ΔE": "ΔE"
+ "ΔE": "ΔE",
+ "action|CLEAR": "BORRAR",
+ "ALPR Cameras": "Cámaras ALPR",
+ "Military aircraft": "Aeronaves militares",
+ "Vessels": "Buques",
+ "CCTV + Street View fallback": "CCTV + respaldo de Street View",
+ "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places opcional",
+ "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · PRONÓSTICO",
+ "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · OBSERVADO",
+ "NASA FIRMS · LIVE": "NASA FIRMS · EN VIVO",
+ "data source|Local": "Datos locales",
+ "overview": "vista general",
+ "{percent}% scene cloud": "{percent}% de nubes en la escena",
+ "{min}–{max}% scene cloud": "{min}–{max}% de nubes en la escena",
+ "Sentinel-2 via HLS": "Sentinel-2 vía HLS",
+ "Landsat 8/9 via HLS": "Landsat 8/9 vía HLS",
+ "coverage|full": "completa",
+ "coverage|partial": "parcial",
+ "m": "m",
+ "km/h": "km/h",
+ "m/s": "m/s",
+ "mph": "mph",
+ "hPa": "hPa",
+ "kt": "kn",
+ "km": "km",
+ "h": "h",
+ "min": "min",
+ "s": "s",
+ "direction|left": "a la izquierda",
+ "direction|right": "a la derecha",
+ "direction|slightly left": "ligeramente a la izquierda",
+ "direction|slightly right": "ligeramente a la derecha",
+ "direction|sharply left": "bruscamente a la izquierda",
+ "direction|sharply right": "bruscamente a la derecha",
+ "direction|straight": "recto",
+ "direction|around": "en sentido contrario",
+ "ordinal|{number}st": "{number}.ª",
+ "ordinal|{number}nd": "{number}.ª",
+ "ordinal|{number}rd": "{number}.ª",
+ "ordinal|{number}th": "{number}.ª",
+ "Head out on {road}": "Sal por {road}",
+ "Head out": "Sal",
+ "Arrive at your destination": "Has llegado a tu destino",
+ "Arrive at your destination, on the left": "Tu destino está a la izquierda",
+ "Arrive at your destination, on the right": "Tu destino está a la derecha",
+ "Make a U-turn": "Da la vuelta",
+ "Make a U-turn onto {road}": "Da la vuelta hacia {road}",
+ "Continue straight": "Sigue recto",
+ "Continue straight onto {road}": "Sigue recto por {road}",
+ "Turn {direction}": "Gira {direction}",
+ "Turn {direction} onto {road}": "Gira {direction} hacia {road}",
+ "maneuver|Turn": "Gira",
+ "Turn onto {road}": "Gira hacia {road}",
+ "Continue {direction}": "Sigue {direction}",
+ "Continue {direction} onto {road}": "Sigue {direction} por {road}",
+ "maneuver|Continue": "Continúa",
+ "Continue onto {road}": "Continúa por {road}",
+ "At the end of the road, turn {direction}": "Al final de la vía, gira {direction}",
+ "At the end of the road, turn {direction} onto {road}": "Al final de la vía, gira {direction} hacia {road}",
+ "At the end of the road, continue": "Al final de la vía, continúa",
+ "At the end of the road, continue onto {road}": "Al final de la vía, continúa por {road}",
+ "Keep {direction} at the fork": "En la bifurcación, mantente {direction}",
+ "Keep {direction} at the fork onto {road}": "En la bifurcación, mantente {direction} hacia {road}",
+ "Continue at the fork": "En la bifurcación, continúa",
+ "Continue at the fork onto {road}": "En la bifurcación, continúa por {road}",
+ "Merge {direction}": "Incorpórate {direction}",
+ "Merge {direction} onto {road}": "Incorpórate {direction} a {road}",
+ "maneuver|Merge": "Incorpórate",
+ "Merge onto {road}": "Incorpórate a {road}",
+ "Take the ramp on the {direction}": "Toma la rampa {direction}",
+ "Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}",
+ "Take the ramp": "Toma la rampa",
+ "Take the ramp onto {road}": "Toma la rampa hacia {road}",
+ "Take the exit on the {direction}": "Toma la salida {direction}",
+ "Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}",
+ "Take the exit": "Toma la salida",
+ "Take the exit onto {road}": "Toma la salida hacia {road}",
+ "At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida",
+ "At the roundabout, take the {exit} exit onto {road}": "En la rotonda, toma la {exit} salida hacia {road}",
+ "Enter the roundabout": "Entra en la rotonda",
+ "Enter the roundabout onto {road}": "Entra en la rotonda hacia {road}",
+ "At the roundabout, turn {direction}": "En la rotonda, gira {direction}",
+ "At the roundabout, turn {direction} onto {road}": "En la rotonda, gira {direction} hacia {road}",
+ "At the roundabout, continue": "En la rotonda, continúa",
+ "At the roundabout, continue onto {road}": "En la rotonda, continúa por {road}",
+ "Exit the roundabout": "Sal de la rotonda",
+ "Exit the roundabout onto {road}": "Sal de la rotonda hacia {road}",
+ "Use the {direction} lane": "Usa el carril {direction}",
+ "Use the {direction} lane onto {road}": "Usa el carril {direction} hacia {road}",
+ "voice|OFF": "APAG.",
+ "voice|CONNECTING": "CONEX…",
+ "voice|LISTENING": "OYENDO",
+ "voice|EXECUTING": "ACCIÓN",
+ "voice|ERROR": "ERROR",
+ "MIC": "MIC",
+ "WORKING": "EN CURSO",
+ "DONE": "LISTO",
+ "Outline traced": "Contorno trazado",
+ "Outline unavailable": "Contorno no disponible",
+ "in progress": "en curso",
+ "done": "listo",
+ "failed": "falló",
+ "cancelled": "cancelado",
+ "view band|STREET": "CALLE",
+ "view band|CITY": "CIUDAD",
+ "view band|METRO": "METRÓPOLI",
+ "view band|REGIONAL": "REGIÓN",
+ "view band|GLOBAL": "GLOBAL",
+ "NEAR {poi} ({city}) {distance}": "CERCA DE {poi} ({city}) {distance}",
+ "SECTOR {lat} {lon}": "SECTOR {lat} {lon}",
+ "ARCTIC": "ÁRTICO",
+ "ANTARCTIC": "ANTÁRTIDA",
+ "NORTH AMERICA": "NORTEAMÉRICA",
+ "SOUTH AMERICA": "SUDAMÉRICA",
+ "EUROPE": "EUROPA",
+ "AFRICA": "ÁFRICA",
+ "ASIA": "ASIA",
+ "OCEANIA": "OCEANÍA",
+ "NORTHERN OCEANIC GRID": "CUADRÍCULA OCEÁNICA NORTE",
+ "SOUTHERN OCEANIC GRID": "CUADRÍCULA OCEÁNICA SUR",
+ "N/A": "N/D",
+ "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | VENTANA {window} | SUN {sun}° | ONA {ona}° | {zone}",
+ "{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
+ "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
+ "{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
+ "ft": "ft",
+ "kts": "kn",
+ "PAUSE": "PAUSA",
+ "RESUME": "REANUDAR",
+ "dB": "dB",
+ "radio|NEWS": "NOTICIAS",
+ "radio|TALK": "TERTULIA",
+ "radio|WEATHER": "CLIMA",
+ "radio|SAFETY": "SEGURIDAD",
+ "radio|AIR / SEA": "AIRE / MAR",
+ "radio|TRANSIT": "TRANSPORTE",
+ "radio|MUSIC": "MÚSICA",
+ "radio|OTHER": "OTROS",
+ "SNAPSHOT": "INSTANTÁNEA",
+ "LIVE": "EN VIVO",
+ "UPSTREAM": "ORIGEN",
+ "SYNTHETIC": "SINTÉTICO",
+ "CONFIGURED": "CONFIGURADO",
+ "SEED": "CATÁLOGO",
+ "Live HLS connected": "HLS en vivo conectado",
+ "Live HLS unavailable": "HLS en vivo no disponible",
+ "No stream URL configured": "No hay URL de transmisión configurada",
+ "Live stream connected": "Transmisión en vivo conectada",
+ "Snapshot feed connected": "Fuente de instantáneas conectada",
+ "Media fetch failed": "Error al obtener el contenido multimedia",
+ "Upstream snapshot active": "Instantánea de origen activa",
+ "Fallback Street View frame": "Fotograma de respaldo de Street View",
+ "Upstream unavailable": "Origen no disponible",
+ "No source configured": "No hay fuente configurada",
+ "Upstream HTTP {status}": "Origen: HTTP {status}",
+ "Unexpected media type {type}": "Tipo de contenido inesperado: {type}",
+ "Configured source": "Fuente configurada",
+ "Synthetic fallback": "Respaldo sintético",
+ "Cancel": "Cancelar",
+ "Choose files": "Elegir archivos",
+ "No file chosen": "Ningún archivo seleccionado",
+ "{count} files selected": {
+ "one": "{count} archivo seleccionado",
+ "many": "{count} de archivos seleccionados",
+ "other": "{count} archivos seleccionados"
+ },
+ "Rasuwagadhi witness video": "Video del testigo de Rasuwagadhi",
+ "Bhote Koshi valley footage": "Imágenes del valle del Bhote Koshi",
+ "Flood footage": "Imágenes de la inundación",
+ "Compilation and casualty claims": "Recopilación y afirmaciones sobre víctimas",
+ "SOURCE GEOCONFIRMED · WORLD-PINNED": "FUENTE GEOCONFIRMADA · FIJADA EN EL MAPA",
+ "SOURCE CREDITED · LOCATION UNRESOLVED": "FUENTE ACREDITADA · UBICACIÓN SIN RESOLVER",
+ "REFERENCE ONLY · CLAIMS UNVERIFIED": "SOLO REFERENCIA · AFIRMACIONES SIN VERIFICAR",
+ "SOURCE GEOCONFIRMED": "FUENTE GEOCONFIRMADA",
+ "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "Referencia histórica de 2021 frente a imágenes posteriores al evento de 2026; los cambios son acumulativos e incluyen la inundación de Rasuwagadhi de julio de 2025. Avance esquemático aguas abajo, no un tiempo de llegada modelado; no es un modelo oficial de peligro.",
+ "Immediate collapse viewpoint": "Punto de vista junto al colapso",
+ "Collapse viewpoint": "Vista del colapso",
+ "Debris-dammed lake": "Lago represado por escombros",
+ "Dammed lake": "Lago represado",
+ "Second landslide": "Segundo deslizamiento",
+ "Gyirong border gate": "Paso fronterizo de Gyirong",
+ "Border gate": "Paso fronterizo",
+ "Timure evidence cluster": "Grupo de evidencias de Timure",
+ "Mailung / Upper Trishuli bridge": "Mailung / puente de Upper Trishuli",
+ "Dandagaun viewpoint restaurant": "Restaurante mirador de Dandagaun",
+ "Dandagaun viewpoint": "Mirador de Dandagaun",
+ "Bidur - Trishuli bridge": "Puente Bidur - Trishuli",
+ "Bidur bridge": "Puente de Bidur",
+ "Devighat / Taadi Khola Bridge": "Devighat / puente de Taadi Khola",
+ "Devighat bridge": "Puente de Devighat",
+ "Global Incident Context": "Contexto global del incidente",
+ "Nepal-Focused Globe Rotation": "Rotación del globo hacia Nepal",
+ "Bhote Koshi Regional Approach": "Aproximación regional al Bhote Koshi",
+ "Bhote Koshi Nearby Cities": "Ciudades cercanas al Bhote Koshi",
+ "Bhote Koshi Incident Corridor": "Corredor del incidente del Bhote Koshi",
+ "Bhote Koshi Flood Path": "Trayectoria de la inundación del Bhote Koshi",
+ "Bhote Koshi Corridor Overview": "Vista general del corredor del Bhote Koshi",
+ "Bhote Koshi Upper Valley": "Valle alto del Bhote Koshi",
+ "Upper Valley Collapse": "Colapso en el valle alto",
+ "Debris-Dammed Lake": "Lago represado por escombros",
+ "Nepal-China Border Gate": "Paso fronterizo Nepal-China",
+ "Timure Evidence Cluster": "Grupo de evidencias de Timure",
+ "Syabru Besi Passage": "Paso por Syabru Besi",
+ "Bidur / Trishuli Consequence": "Consecuencias en Bidur / Trishuli",
+ "Final view": "Vista final",
+ "{year} HISTORICAL REFERENCE": "REFERENCIA HISTÓRICA {year}",
+ "{year} POST-EVENT": "POSTERIOR AL EVENTO {year}",
+ "Esri map unavailable · no swipe": "Mapa Esri no disponible · sin comparación",
+ "Swipe needs a globe map": "La comparación necesita un mapa de globo",
+ "Imagery stays on Esri · CLEAR to use Google 3D": "Las imágenes siguen en Esri · BORRAR para usar Google 3D",
+ "Box and images cleared": "Recuadro e imágenes borrados",
+ "Box is {size} wide · limit {limit}": "El recuadro mide {size} · límite {limit}",
+ "View is {size} wide · limit {limit}": "La vista mide {size} · límite {limit}"
}
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index fcfa7812c..5ffb0d905 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -73,7 +73,7 @@
"Remove this key from your saved configuration?": "Удалить этот ключ из сохранённой конфигурации?",
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
"More flight-polling credits (anonymous works without)": "Больше кредитов на опрос рейсов (анонимно работает и без ключа)",
- "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его Client Token",
+ "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token)",
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
"The photorealistic 3D planet + place search": "Фотореалистичная 3D-планета + поиск мест",
"Places context + Street View fallback; optional separate key": "Контекст Places + резервный Street View; отдельный ключ — по желанию",
@@ -81,7 +81,7 @@
"Live ships, worldwide": "Суда в реальном времени по всему миру",
"Live active-fire detections": "Обнаружение активных пожаров в реальном времени",
"Real live traffic (keyless runs a simulation)": "Реальные пробки в реальном времени (без ключа — симуляция)",
- "Bing imagery map stacks + world terrain": "Наборы карт Bing imagery + рельеф мира",
+ "Bing imagery map stacks + world terrain": "Снимки Bing + рельеф мира",
"Global Context navigation": "Навигация глобального контекста",
"Previous — prior visited contact in the 250 km window": "Назад — предыдущий посещённый контакт в радиусе 250 км",
"PREVIOUS": "ПРЕД.",
@@ -295,7 +295,7 @@
"CCTV layer unavailable": "Слой CCTV недоступен",
"Link copied!": "Ссылка скопирована!",
"Copy failed": "Не удалось скопировать",
- "VISUAL PRESETS": "ВИЗУАЛЬНЫЕ ПРЕСЕТЫ",
+ "VISUAL PRESETS": "ПРЕСЕТЫ",
"LOCATION": "МЕСТО",
"DISPLAY": "ЭКРАН",
"CONTACT": "КОНТАКТ",
@@ -315,8 +315,8 @@
"COVERAGE ON": "ОХВАТ ВКЛ",
"COVERAGE OFF": "ОХВАТ ВЫКЛ",
"RAW PRIOR": "ИСХОДНАЯ ОЦЕНКА",
- "AUTO HOP ON": "АВТОПЕРЕХОД ВКЛ",
- "AUTO HOP OFF": "АВТОПЕРЕХОД ВЫКЛ",
+ "AUTO HOP ON": "АВТОСМЕНА ВКЛ",
+ "AUTO HOP OFF": "АВТОСМЕНА ВЫКЛ",
"PROJECTION ON": "ПРОЕКЦИЯ ВКЛ",
"PROJECTION OFF": "ПРОЕКЦИЯ ВЫКЛ",
"CAL · EDITED (UNSAVED)": "КАЛИБР. · ИЗМЕНЕНО (НЕ СОХР.)",
@@ -369,9 +369,9 @@
"CLOUD UNKNOWN": "ОБЛАЧНОСТЬ НЕИЗВЕСТНА",
"MIXED CONDITIONS": "СМЕШАННЫЕ УСЛОВИЯ",
"{source} · LOCATION QUERY": "{source} · ЗАПРОС ПО МЕСТУ",
- "REGIONAL NEWS": "РЕГИОНАЛЬНЫЕ НОВОСТИ",
- "CYCLE ON": "АВТОЛИСТАНИЕ ВКЛ",
- "CYCLE OFF": "АВТОЛИСТАНИЕ ВЫКЛ",
+ "REGIONAL NEWS": "НОВОСТИ РЕГИОНА",
+ "CYCLE ON": "АВТО: ВКЛ",
+ "CYCLE OFF": "АВТО: ВЫКЛ",
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Остановить автоматическое листание страниц. Кнопки ПРЕД./СЛЕД. и вкладки СИГН./НОВОСТИ/МЕСТН. остаются доступны.",
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Автоматически листать страницы брифинга каждые 9 секунд (Сигналы → Новости → Местное). При наведении или фокусе на панели листание приостанавливается. Данные сигналов в реальном времени в любом случае обновляются непрерывно.",
"{cohort}, Unavailable": "{cohort}, недоступно",
@@ -423,10 +423,10 @@
"Collapse briefing panel": "Свернуть панель брифинга",
"Expand briefing panel": "Развернуть панель брифинга",
"OBSERVED / MAPPED PINGS": "НАБЛЮДАЕМЫЕ / НАНЕСЁННЫЕ ПИНГИ",
- "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "СОБЫТИЯ С ИСТОЧНИКАМИ · БЕЗ СИНТЕТИЧЕСКИХ НОВОСТЕЙ",
+ "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "СОБЫТИЯ ИЗ ИСТОЧНИКОВ · БЕЗ ВЫМЫСЛА",
"LATEST LOCATION-MATCHED REPORTING": "СВЕЖИЕ НОВОСТИ ПО МЕСТУ",
"LOCAL INFO": "МЕСТНЫЕ ДАННЫЕ",
- "PLACE / CONDITIONS / POSITION": "МЕСТО / УСЛОВИЯ / ПОЗИЦИЯ",
+ "PLACE / CONDITIONS / POSITION": "МЕСТО / ПОГОДА / КООРД.",
"TIME UNKNOWN": "ВРЕМЯ НЕИЗВЕСТНО",
"{count}M AGO": "{count} МИН НАЗАД",
"{count}H AGO": "{count} Ч НАЗАД",
@@ -480,16 +480,16 @@
"DEGRADED": "ПЕРЕБОИ",
"PARTIAL": "ЧАСТИЧНО",
"FALLBACK": "РЕЗЕРВ",
- "Live Flights": "Рейсы в реальном времени",
+ "Live Flights": "Рейсы онлайн",
"Military Flights": "Военные рейсы",
- "Earthquakes (24h)": "Землетрясения (24 ч)",
+ "Earthquakes (24h)": "Землетрясения (24 ч)",
"Satellites": "Спутники",
- "Space Missions (30d)": "Космические миссии (30 дн.)",
+ "Space Missions (30d)": "Космические миссии (30 дн.)",
"Street Traffic": "Уличный трафик",
"CCTV": "CCTV",
"Radio": "Радио",
"Bikeshare": "Велошеринг",
- "Live AIS Vessels": "Суда AIS в реальном времени",
+ "Live AIS Vessels": "Суда AIS онлайн",
"Mapped Installations": "Объекты на карте",
"Global Context": "Глобальный контекст",
"Datacenters": "Дата-центры",
@@ -821,7 +821,7 @@
"NO PLACE LEFT BEHIND": "НИ ОДНА ТОЧКА НЕ ОСТАНЕТСЯ ПОЗАДИ",
"ACTIVE STYLE": "АКТИВНЫЙ СТИЛЬ",
"Choose your first view": "Выберите первый вид",
- "Tip: the GEV MIC button in the dock lets you talk to the map.": "Совет: кнопка GEV MIC в доке позволяет говорить с картой.",
+ "Tip: the GEV MIC button in the dock lets you talk to the map.": "Совет: кнопка «МИК» в доке позволяет говорить с картой.",
"Aircraft, vessels and nearby intelligence": "Самолёты, суда и разведданные поблизости",
"Launches, spacecraft and orbital context": "Пуски, космические аппараты и орбитальный контекст",
"Begin with a clean globe": "Начните с чистого глобуса",
@@ -847,8 +847,8 @@
"VOICE SYSTEM ERROR": "ОШИБКА ГОЛОСОВОЙ СИСТЕМЫ",
"DISMISS": "СКРЫТЬ",
"Check microphone permission and network access, then try again.": "Проверьте разрешение на микрофон и доступ к сети, затем попробуйте снова.",
- "Release Space to send": "Отпустите Space, чтобы отправить",
- "Hold Space to talk": "Удерживайте Space, чтобы говорить",
+ "Release Space to send": "Отпустите Space для отправки",
+ "Hold Space to talk": "Держите Space и говорите",
"Ask or command": "Спросите или дайте команду",
"WebRTC microphone support unavailable": "Поддержка микрофона WebRTC недоступна",
"Requesting microphone": "Запрос доступа к микрофону",
@@ -857,8 +857,6 @@
"VOICE ACTIVE": "ГОЛОС АКТИВЕН",
"Voice session could not be started.": "Не удалось запустить голосовую сессию.",
"CONNECTING": "ПОДКЛЮЧЕНИЕ",
- "LISTENING": "СЛУШАЮ",
- "EXECUTING": "ВЫПОЛНЕНИЕ",
"ERROR": "ОШИБКА",
"{tier} applies next session": "{tier} вступит в силу со следующей сессии",
"Session ended — cost cap {cost}": "Сессия завершена — лимит стоимости {cost}",
@@ -917,7 +915,7 @@
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
"Selected": "Выбранная",
- "Live Vessels": "Суда в реальном времени",
+ "Live Vessels": "Суда онлайн",
"Bike Share": "Прокат велосипедов",
"Cameras": "Камеры",
"Street Level": "Уличные снимки",
@@ -999,7 +997,7 @@
"{provider} · EMBEDDED SOURCE": "{provider} · ВСТРОЕННЫЙ ИСТОЧНИК",
"OPEN ORIGINAL ↗": "ОТКРЫТЬ ОРИГИНАЛ ↗",
"{provider} media: {source}": "Медиа {provider}: {source}",
- "{provider} · PROVIDER CONTROLS": "{provider} · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
+ "{provider} · PROVIDER CONTROLS": "{provider} · ПЛЕЕР ПРОВАЙДЕРА",
"YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ОТКРЫТЬ ОРИГИНАЛ",
"Facebook player API did not initialize.": "API плеера Facebook не инициализировался.",
"Facebook player API could not load.": "Не удалось загрузить API плеера Facebook.",
@@ -1013,10 +1011,10 @@
"Preloading {provider} media": "Предзагрузка медиа {provider}",
"LOADING X POST…": "ЗАГРУЗКА ПОСТА X…",
"The X post is unavailable or cannot be embedded.": "Пост X недоступен или не может быть встроен.",
- "X POST · PROVIDER CONTROLS": "ПОСТ X · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
+ "X POST · PROVIDER CONTROLS": "ПОСТ X · ВИДЖЕТ ПРОВАЙДЕРА",
"{reason} OPEN ORIGINAL.": "{reason} ОТКРОЙТЕ ОРИГИНАЛ.",
"X embed unavailable": "Встраивание X недоступно",
- "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · УПРАВЛЕНИЕ ПРОВАЙДЕРА",
+ "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · ПЛЕЕР ПРОВАЙДЕРА",
"Facebook embed unavailable": "Встраивание Facebook недоступно",
"AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "АВТОВОСПРОИЗВЕДЕНИЕ ЗАБЛОКИРОВАНО · ИСПОЛЬЗУЙТЕ УПРАВЛЕНИЕ ПРОВАЙДЕРА",
"Embedded {provider} media for {source}": "Встроенное медиа {provider}: {source}",
@@ -1094,7 +1092,7 @@
"▶ PLAY SCENE": "▶ ПУСК СЦЕНЫ",
"◉ CINEMATIC": "◉ КИНОРЕЖИМ",
"↺ FULL STORY": "↺ ВСЯ ИСТОРИЯ",
- "↗ OPEN ORIGINAL": "↗ ОТКРЫТЬ ОРИГИНАЛ",
+ "↗ OPEN ORIGINAL": "↗ ОРИГИНАЛ",
"⌖ LOWER GORGE": "⌖ НИЖНЕЕ УЩЕЛЬЕ",
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
@@ -1146,10 +1144,10 @@
"Camera badges": "Значки камер",
"Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Голубые камеры при выборе становятся коралловыми. Секторы показывают направление по карте, а не измеренное покрытие. Ближайшие камеры могут быть за пределами экрана.",
"{count} nearby · {onScreen} on screen": {
- "one": "рядом: {count} · на экране: {onScreen}",
- "few": "рядом: {count} · на экране: {onScreen}",
- "many": "рядом: {count} · на экране: {onScreen}",
- "other": "рядом: {count} · на экране: {onScreen}"
+ "one": "{count} рядом · {onScreen} видно",
+ "few": "{count} рядом · {onScreen} видно",
+ "many": "{count} рядом · {onScreen} видно",
+ "other": "{count} рядом · {onScreen} видно"
},
"{count} nearby": {
"one": "рядом: {count}",
@@ -1319,7 +1317,7 @@
"NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "Радиолокационные эхосигналы NOAA MRMS показывают картину осадков, а не их интенсивность, штормовое предупреждение или прогноз. Исходное разрешение около 1 км; отображение ограничено уровнем 6. Кадры используют точное заявленное время наблюдений.",
"GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "Региональный длинноволновый ИК-канал 14 GOES-19/18; глобальная длинноволновая мозаика NESDIS. Режим «Только облака» затемняет всё, кроме ярких холодных вершин облаков; это фильтр яркости, а не маска облачности. Покрытие и актуальность различаются по регионам.",
"REGION": "РЕГИОН",
- "IMAGE": "ИЗОБРАЖЕНИЕ",
+ "IMAGE": "СНИМОК",
"OPACITY": "НЕПРОЗРАЧНОСТЬ",
"Forecast · does not follow history": "Прогноз · не следует за историей",
"Wind motion": "Движение ветра",
@@ -1543,13 +1541,13 @@
"Mode": "Режим",
"Imagery on Esri · Google 3D returns when cleared": "Снимки поверх Esri · после очистки вернётся Google 3D",
"Imagery opacity": "Непрозрачность снимков",
- "SWAP": "ПОМЕНЯТЬ",
+ "SWAP": "СМЕНА",
"VS BASEMAP": "VS ПОДЛОЖКА",
"Trade the two sides of the divider": "Поменять местами стороны разделителя",
"A / B": "A / B",
"Newest low-cloud day for this box · scene cloud, not box cloud": "Самый свежий малооблачный день для этой области · облачность всей сцены, а не области",
"Newest day covering this box · cloudier than 20%": "Самый свежий день с покрытием этой области · облачность выше 20%",
- "SELECT BOX": "ВЫБРАТЬ ОБЛАСТЬ",
+ "SELECT BOX": "ВЫДЕЛИТЬ",
"Drag a box on the map (Esc cancels)": "Выделите область на карте (Esc — отмена)",
"Newest day with imagery for this box · partial coverage": "Самый свежий день со снимками этой области · частичное покрытие",
"USE VIEW": "ПО ВИДУ",
@@ -1583,7 +1581,7 @@
"Source off": "Источник выключен",
"Unpin {slot}": "Открепить {slot}",
"Unpin the image": "Открепить снимок",
- "EXPORT {slot}": "ЭКСПОРТ {slot}",
+ "EXPORT {slot}": "ЭКСП. {slot}",
"EXPORT": "ЭКСПОРТ",
"Download {slot} as a PNG": "Скачать {slot} в PNG",
"Download the image as a PNG": "Скачать снимок в PNG",
@@ -1622,7 +1620,7 @@
"Select a box or use the view": "Выделите область или используйте текущий вид",
"Searching the last 30 days": "Поиск за последние 30 дней",
"No days to show for this box": "Для этой области нет дней для показа",
- "Drag the divider · SWAP trades sides": "Перетащите разделитель · ПОМЕНЯТЬ меняет стороны",
+ "Drag the divider · SWAP trades sides": "Перетащите разделитель · СМЕНА меняет стороны",
"Export failed · {error}": "Ошибка экспорта · {error}",
"← → preview · A or B pins the focused day": "← → просмотр · A или B закрепляет выбранный день",
"← → preview the other side · A or B pins it": "← → просмотр другой стороны · A или B закрепляет её",
@@ -1662,7 +1660,7 @@
"GAIN": "УСИЛЕНИЕ",
"FM MHZ": "FM МГЦ",
"AUTO": "АВТО",
- "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальный тюнер для FM и слоя Local ADS-B.",
+ "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · один локальный тюнер для FM и слоя «Локальный ADS-B».",
"RTL-SDR tuner gain for the current mode": "Усиление тюнера RTL-SDR для текущего режима",
"WEATHER": "ПОГОДА",
"Active weather products": "Активные погодные продукты",
@@ -1699,7 +1697,7 @@
"Forecast · valid {valid}": "Прогноз · действует на {valid}",
"{time} · {minutes} min ago": "{time} · {minutes} мин назад",
"Active weather": "Активная погода",
- "MINI": "MINI",
+ "MINI": "МИНИ",
"STD": "СТД",
"standard": "стандартную",
"mini": "mini",
@@ -1832,5 +1830,218 @@
"Referenced items": "Упомянутые объекты",
"NOTES & SOURCES": "ЗАМЕТКИ И ИСТОЧНИКИ",
"ΔN": "ΔС",
- "ΔE": "ΔВ"
+ "ΔE": "ΔВ",
+ "action|CLEAR": "СБРОСИТЬ",
+ "ALPR Cameras": "Камеры ALPR",
+ "Military aircraft": "Военные самолёты",
+ "Vessels": "Суда",
+ "CCTV + Street View fallback": "CCTV + резервный Street View",
+ "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places (опц.)",
+ "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · ПРОГНОЗ",
+ "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · НАБЛЮДЕНИЕ",
+ "NASA FIRMS · LIVE": "NASA FIRMS · ОНЛАЙН",
+ "data source|Local": "Локальные данные",
+ "overview": "обзор",
+ "{percent}% scene cloud": "облачность сцены {percent}%",
+ "{min}–{max}% scene cloud": "облачность сцены {min}–{max}%",
+ "Sentinel-2 via HLS": "Sentinel-2 через HLS",
+ "Landsat 8/9 via HLS": "Landsat 8/9 через HLS",
+ "coverage|full": "полное",
+ "coverage|partial": "частичное",
+ "m": "м",
+ "km/h": "км/ч",
+ "m/s": "м/с",
+ "mph": "миль/ч",
+ "hPa": "гПа",
+ "kt": "уз",
+ "km": "км",
+ "h": "ч",
+ "min": "мин",
+ "s": "с",
+ "direction|left": "налево",
+ "direction|right": "направо",
+ "direction|slightly left": "плавно налево",
+ "direction|slightly right": "плавно направо",
+ "direction|sharply left": "резко налево",
+ "direction|sharply right": "резко направо",
+ "direction|straight": "прямо",
+ "direction|around": "в обратную сторону",
+ "ordinal|{number}st": "{number}-й",
+ "ordinal|{number}nd": "{number}-й",
+ "ordinal|{number}rd": "{number}-й",
+ "ordinal|{number}th": "{number}-й",
+ "Head out on {road}": "Начните движение по {road}",
+ "Head out": "Начните движение",
+ "Arrive at your destination": "Вы прибыли в пункт назначения",
+ "Arrive at your destination, on the left": "Пункт назначения слева",
+ "Arrive at your destination, on the right": "Пункт назначения справа",
+ "Make a U-turn": "Развернитесь",
+ "Make a U-turn onto {road}": "Развернитесь на {road}",
+ "Continue straight": "Продолжайте прямо",
+ "Continue straight onto {road}": "Продолжайте прямо на {road}",
+ "Turn {direction}": "Поверните {direction}",
+ "Turn {direction} onto {road}": "Поверните {direction} на {road}",
+ "maneuver|Turn": "Поверните",
+ "Turn onto {road}": "Поверните на {road}",
+ "Continue {direction}": "Продолжайте движение {direction}",
+ "Continue {direction} onto {road}": "Продолжайте движение {direction} на {road}",
+ "maneuver|Continue": "Продолжайте движение",
+ "Continue onto {road}": "Продолжайте движение по {road}",
+ "At the end of the road, turn {direction}": "В конце дороги поверните {direction}",
+ "At the end of the road, turn {direction} onto {road}": "В конце дороги поверните {direction} на {road}",
+ "At the end of the road, continue": "В конце дороги продолжайте движение",
+ "At the end of the road, continue onto {road}": "В конце дороги продолжайте движение по {road}",
+ "Keep {direction} at the fork": "На развилке держитесь {direction}",
+ "Keep {direction} at the fork onto {road}": "На развилке держитесь {direction} на {road}",
+ "Continue at the fork": "На развилке продолжайте движение",
+ "Continue at the fork onto {road}": "На развилке продолжайте движение по {road}",
+ "Merge {direction}": "Перестройтесь {direction}",
+ "Merge {direction} onto {road}": "Перестройтесь {direction} на {road}",
+ "maneuver|Merge": "Перестройтесь",
+ "Merge onto {road}": "Въезжайте на {road}",
+ "Take the ramp on the {direction}": "Въезжайте на съезд {direction}",
+ "Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
+ "Take the ramp": "Въезжайте на съезд",
+ "Take the ramp onto {road}": "Въезжайте по съезду на {road}",
+ "Take the exit on the {direction}": "Сверните на съезд {direction}",
+ "Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}",
+ "Take the exit": "Сверните на съезд",
+ "Take the exit onto {road}": "Сверните на съезд на {road}",
+ "At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд",
+ "At the roundabout, take the {exit} exit onto {road}": "На кольце сверните на {exit} съезд на {road}",
+ "Enter the roundabout": "Въезжайте на кольцо",
+ "Enter the roundabout onto {road}": "Въезжайте на кольцо, выезд на {road}",
+ "At the roundabout, turn {direction}": "На кольце поверните {direction}",
+ "At the roundabout, turn {direction} onto {road}": "На кольце поверните {direction} на {road}",
+ "At the roundabout, continue": "На кольце продолжайте движение",
+ "At the roundabout, continue onto {road}": "На кольце продолжайте движение на {road}",
+ "Exit the roundabout": "Съезжайте с кольца",
+ "Exit the roundabout onto {road}": "Съезжайте с кольца на {road}",
+ "Use the {direction} lane": "Займите полосу для движения {direction}",
+ "Use the {direction} lane onto {road}": "Займите полосу для движения {direction} на {road}",
+ "voice|OFF": "ВЫКЛ",
+ "voice|CONNECTING": "СВЯЗЬ…",
+ "voice|LISTENING": "СЛУШАЮ",
+ "voice|EXECUTING": "ДЕЛАЮ",
+ "voice|ERROR": "ОШИБКА",
+ "MIC": "МИК",
+ "WORKING": "В РАБОТЕ",
+ "DONE": "ГОТОВО",
+ "Outline traced": "Контур построен",
+ "Outline unavailable": "Контур недоступен",
+ "in progress": "выполняется",
+ "done": "готово",
+ "failed": "ошибка",
+ "cancelled": "отменено",
+ "view band|STREET": "УЛИЦА",
+ "view band|CITY": "ГОРОД",
+ "view band|METRO": "АГЛОМЕРАЦИЯ",
+ "view band|REGIONAL": "РЕГИОН",
+ "view band|GLOBAL": "ПЛАНЕТА",
+ "NEAR {poi} ({city}) {distance}": "РЯДОМ: {poi} ({city}) {distance}",
+ "SECTOR {lat} {lon}": "СЕКТОР {lat} {lon}",
+ "ARCTIC": "АРКТИКА",
+ "ANTARCTIC": "АНТАРКТИКА",
+ "NORTH AMERICA": "СЕВЕРНАЯ АМЕРИКА",
+ "SOUTH AMERICA": "ЮЖНАЯ АМЕРИКА",
+ "EUROPE": "ЕВРОПА",
+ "AFRICA": "АФРИКА",
+ "ASIA": "АЗИЯ",
+ "OCEANIA": "ОКЕАНИЯ",
+ "NORTHERN OCEANIC GRID": "СЕВЕРНАЯ ОКЕАНСКАЯ СЕТКА",
+ "SOUTHERN OCEANIC GRID": "ЮЖНАЯ ОКЕАНСКАЯ СЕТКА",
+ "N/A": "Н/Д",
+ "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | ОКНО {window} | SUN {sun}° | ONA {ona}° | {zone}",
+ "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
+ "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
+ "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · СВЕЖЕЕ",
+ "ft": "фт",
+ "kts": "уз",
+ "PAUSE": "ПАУЗА",
+ "RESUME": "ПРОДОЛЖ.",
+ "dB": "дБ",
+ "radio|NEWS": "НОВОСТИ",
+ "radio|TALK": "ТОК-РАДИО",
+ "radio|WEATHER": "ПОГОДА",
+ "radio|SAFETY": "БЕЗОПАСНОСТЬ",
+ "radio|AIR / SEA": "АВИА / МОРЕ",
+ "radio|TRANSIT": "ТРАНСПОРТ",
+ "radio|MUSIC": "МУЗЫКА",
+ "radio|OTHER": "ДРУГОЕ",
+ "SNAPSHOT": "СНИМОК",
+ "LIVE": "ОНЛАЙН",
+ "UPSTREAM": "ИСТОЧНИК",
+ "SYNTHETIC": "СИНТЕТИКА",
+ "CONFIGURED": "НАСТРОЕН",
+ "SEED": "КАТАЛОГ",
+ "Live HLS connected": "Поток HLS подключён",
+ "Live HLS unavailable": "Поток HLS недоступен",
+ "No stream URL configured": "URL потока не настроен",
+ "Live stream connected": "Прямой поток подключён",
+ "Snapshot feed connected": "Поток снимков подключён",
+ "Media fetch failed": "Не удалось получить медиа",
+ "Upstream snapshot active": "Снимок с источника активен",
+ "Fallback Street View frame": "Резервный кадр Street View",
+ "Upstream unavailable": "Источник недоступен",
+ "No source configured": "Источник не настроен",
+ "Upstream HTTP {status}": "Источник: HTTP {status}",
+ "Unexpected media type {type}": "Неожиданный тип медиа: {type}",
+ "Configured source": "Настроенный источник",
+ "Synthetic fallback": "Синтетический резерв",
+ "Cancel": "Отмена",
+ "Choose files": "Выбрать файлы",
+ "No file chosen": "Файлы не выбраны",
+ "{count} files selected": {
+ "one": "Выбрано файлов: {count}",
+ "few": "Выбрано файлов: {count}",
+ "many": "Выбрано файлов: {count}",
+ "other": "Выбрано файлов: {count}"
+ },
+ "Rasuwagadhi witness video": "Видео очевидца в Rasuwagadhi",
+ "Bhote Koshi valley footage": "Съёмка долины Bhote Koshi",
+ "Flood footage": "Съёмка паводка",
+ "Compilation and casualty claims": "Подборка и заявления о жертвах",
+ "SOURCE GEOCONFIRMED · WORLD-PINNED": "ИСТОЧНИК ГЕОПОДТВЕРЖДЁН · ОТМЕЧЕН НА КАРТЕ",
+ "SOURCE CREDITED · LOCATION UNRESOLVED": "АВТОР УКАЗАН · МЕСТО НЕ УСТАНОВЛЕНО",
+ "REFERENCE ONLY · CLAIMS UNVERIFIED": "ТОЛЬКО ДЛЯ СПРАВКИ · ЗАЯВЛЕНИЯ НЕ ПРОВЕРЕНЫ",
+ "SOURCE GEOCONFIRMED": "ИСТОЧНИК ГЕОПОДТВЕРЖДЁН",
+ "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "Исторический снимок 2021 года в сравнении со снимками 2026 года после события; изменения накопительные и включают паводок в Rasuwagadhi в июле 2025 года. Схематическое продвижение вниз по течению, а не смоделированное время прихода; не официальная модель опасности.",
+ "Immediate collapse viewpoint": "Точка обзора у места обрушения",
+ "Collapse viewpoint": "Обзор обрушения",
+ "Debris-dammed lake": "Озеро, запруженное обломками",
+ "Dammed lake": "Запрудное озеро",
+ "Second landslide": "Второй оползень",
+ "Gyirong border gate": "Погранпереход Gyirong",
+ "Border gate": "Погранпереход",
+ "Timure evidence cluster": "Группа свидетельств у Timure",
+ "Mailung / Upper Trishuli bridge": "Mailung / мост Upper Trishuli",
+ "Dandagaun viewpoint restaurant": "Ресторан со смотровой площадкой, Dandagaun",
+ "Dandagaun viewpoint": "Смотровая площадка Dandagaun",
+ "Bidur - Trishuli bridge": "Мост Bidur — Trishuli",
+ "Bidur bridge": "Мост Bidur",
+ "Devighat / Taadi Khola Bridge": "Devighat / мост Taadi Khola",
+ "Devighat bridge": "Мост Devighat",
+ "Global Incident Context": "Глобальный контекст инцидента",
+ "Nepal-Focused Globe Rotation": "Поворот глобуса к Непалу",
+ "Bhote Koshi Regional Approach": "Подлёт к региону Bhote Koshi",
+ "Bhote Koshi Nearby Cities": "Города рядом с Bhote Koshi",
+ "Bhote Koshi Incident Corridor": "Коридор инцидента на Bhote Koshi",
+ "Bhote Koshi Flood Path": "Путь паводка на Bhote Koshi",
+ "Bhote Koshi Corridor Overview": "Обзор коридора Bhote Koshi",
+ "Bhote Koshi Upper Valley": "Верхняя долина Bhote Koshi",
+ "Upper Valley Collapse": "Обрушение в верхней долине",
+ "Debris-Dammed Lake": "Озеро, запруженное обломками",
+ "Nepal-China Border Gate": "Погранпереход Непал — Китай",
+ "Timure Evidence Cluster": "Группа свидетельств у Timure",
+ "Syabru Besi Passage": "Прохождение Syabru Besi",
+ "Bidur / Trishuli Consequence": "Последствия в Bidur / Trishuli",
+ "Final view": "Финальный вид",
+ "{year} HISTORICAL REFERENCE": "{year} ИСТОРИЧЕСКИЙ СНИМОК",
+ "{year} POST-EVENT": "{year} ПОСЛЕ СОБЫТИЯ",
+ "Esri map unavailable · no swipe": "Карта Esri недоступна · без сравнения",
+ "Swipe needs a globe map": "Для сравнения нужна карта-глобус",
+ "Imagery stays on Esri · CLEAR to use Google 3D": "Снимки остаются на Esri · СБРОСИТЬ, чтобы вернуть Google 3D",
+ "Box and images cleared": "Область и снимки сброшены",
+ "Box is {size} wide · limit {limit}": "Область шириной {size} · предел {limit}",
+ "View is {size} wide · limit {limit}": "Вид шириной {size} · предел {limit}"
}
diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js
index f793dcefc..1b775249b 100644
--- a/src/layers/cyclones/index.js
+++ b/src/layers/cyclones/index.js
@@ -15,9 +15,13 @@ import {
cycloneStormIdFromEntryId,
} from './labels.js';
import { t, tn, msg } from '../../i18n/index.js';
+import { formatQuantity } from '../../ui/unitLabels.js';
+// "10-12 15:00 UTC" never breaks across lines.
const utc = (value) =>
- value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : t('Unavailable');
+ value
+ ? `${value.slice(5, 16).replace('T', '\u00a0')}\u00a0UTC`
+ : t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
);
@@ -34,7 +38,7 @@ const classificationName = (code) =>
? t(CLASSIFICATION_NAMES[code])
: code;
const number = (value, unit) =>
- value === null ? t('Unavailable') : `${value} ${unit}`;
+ value === null ? t('Unavailable') : formatQuantity(value, unit);
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
@@ -411,7 +415,7 @@ export function createCyclonesLayer({
id: item.id,
ordinal: index + 1,
lead: item.basin,
- text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : `${item.windKt} kt`}`,
+ text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs
index 4127871f4..d0db87d05 100644
--- a/src/layers/cyclones/index.test.mjs
+++ b/src/layers/cyclones/index.test.mjs
@@ -565,7 +565,8 @@ test('advisory selection uses accessible row descriptors and shared camera hando
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
- assert.match(controls.info, /09-16 03:00 UTC/);
+ assert.match(controls.info, /09-16\u00a003:00\u00a0UTC/);
+ assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js
index 4ca9ceb12..045b7f106 100644
--- a/src/layers/directions/index.js
+++ b/src/layers/directions/index.js
@@ -17,11 +17,9 @@
*/
import * as Cesium from 'cesium';
-import {
- formatRouteDistance,
- formatRouteDuration,
-} from '../../data/routeSteps.js';
-import { t, msg } from '../../i18n/index.js';
+import { roadLabel } from '../../data/routeSteps.js';
+import { t, msg, formatNumber } from '../../i18n/index.js';
+import { formatQuantity } from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -69,6 +67,219 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
/** How often the row repaints the highlighted step while FLY is running (ms). */
export const FLIGHT_PROGRESS_MS = 250;
+// ---------------------------------------------------------------------------
+// Display text: distances, durations and maneuvers in the page's language.
+// The proxy's English `instruction` stays the machine value; the row and the
+// cards phrase each step from its OSRM type/modifier/road instead.
+// ---------------------------------------------------------------------------
+
+/**
+ * Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
+ * in src/data/routeSteps.js); number and unit never break apart.
+ * @param {number} meters
+ * @returns {string}
+ */
+export function routeDistanceText(meters) {
+ if (!Number.isFinite(meters) || meters < 0) return '';
+ if (meters < 100) return formatQuantity(Math.round(meters), 'm');
+ if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
+ const km = meters / 1000;
+ return km < 10
+ ? formatQuantity(km, 'km', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ })
+ : formatQuantity(Math.round(km), 'km');
+}
+
+/**
+ * Localized "40 s", "12 min", "1 h 5 min" (the rounding of
+ * `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
+ * @param {number} seconds
+ * @returns {string}
+ */
+export function routeDurationText(seconds) {
+ if (!Number.isFinite(seconds) || seconds < 0) return '';
+ if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return formatQuantity(minutes, 'min');
+ const hours = formatQuantity(Math.floor(minutes / 60), 'h');
+ const rest = minutes % 60;
+ return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
+}
+
+/** "left", "slightly right", … as it reads inside a maneuver sentence. */
+function directionWord(modifier) {
+ switch (modifier) {
+ case 'left':
+ return t('left', { context: 'direction' });
+ case 'right':
+ return t('right', { context: 'direction' });
+ case 'slight left':
+ return t('slightly left', { context: 'direction' });
+ case 'slight right':
+ return t('slightly right', { context: 'direction' });
+ case 'sharp left':
+ return t('sharply left', { context: 'direction' });
+ case 'sharp right':
+ return t('sharply right', { context: 'direction' });
+ case 'straight':
+ return t('straight', { context: 'direction' });
+ case 'uturn':
+ return t('around', { context: 'direction' });
+ default:
+ return modifier;
+ }
+}
+
+/** "1st", "2nd", … for a roundabout exit. */
+function exitOrdinal(exit) {
+ const number = formatNumber(exit);
+ switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
+ case 'one':
+ return t('{number}st', { context: 'ordinal', number });
+ case 'two':
+ return t('{number}nd', { context: 'ordinal', number });
+ case 'few':
+ return t('{number}rd', { context: 'ordinal', number });
+ default:
+ return t('{number}th', { context: 'ordinal', number });
+ }
+}
+
+/**
+ * One maneuver in the page's language, from the step's OSRM type, modifier,
+ * exit and road. English matches `instructionFor` in src/data/routeSteps.js
+ * word for word. A step without a type (an older payload) keeps the proxy's
+ * English instruction.
+ * @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
+ * @returns {string}
+ */
+export function directionsInstruction(step) {
+ const type =
+ typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
+ if (!type)
+ return typeof step?.instruction === 'string' ? step.instruction : '';
+ const modifier =
+ typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
+ const road = roadLabel(step);
+ const direction = directionWord(modifier);
+ const values = { road, direction };
+ switch (type) {
+ case 'depart':
+ return road ? t('Head out on {road}', values) : t('Head out');
+ case 'arrive':
+ if (modifier === 'left')
+ return t('Arrive at your destination, on the left');
+ if (modifier === 'right')
+ return t('Arrive at your destination, on the right');
+ return t('Arrive at your destination');
+ case 'turn':
+ if (modifier === 'uturn')
+ return road
+ ? t('Make a U-turn onto {road}', values)
+ : t('Make a U-turn');
+ if (modifier === 'straight')
+ return road
+ ? t('Continue straight onto {road}', values)
+ : t('Continue straight');
+ if (direction)
+ return road
+ ? t('Turn {direction} onto {road}', values)
+ : t('Turn {direction}', values);
+ return road
+ ? t('Turn onto {road}', values)
+ : t('Turn', { context: 'maneuver' });
+ case 'continue':
+ if (modifier === 'uturn')
+ return road
+ ? t('Make a U-turn onto {road}', values)
+ : t('Make a U-turn');
+ if (direction && modifier !== 'straight')
+ return road
+ ? t('Continue {direction} onto {road}', values)
+ : t('Continue {direction}', values);
+ return road
+ ? t('Continue straight onto {road}', values)
+ : t('Continue straight');
+ case 'end of road':
+ if (direction)
+ return road
+ ? t('At the end of the road, turn {direction} onto {road}', values)
+ : t('At the end of the road, turn {direction}', values);
+ return road
+ ? t('At the end of the road, continue onto {road}', values)
+ : t('At the end of the road, continue');
+ case 'fork':
+ if (direction)
+ return road
+ ? t('Keep {direction} at the fork onto {road}', values)
+ : t('Keep {direction} at the fork', values);
+ return road
+ ? t('Continue at the fork onto {road}', values)
+ : t('Continue at the fork');
+ case 'merge':
+ if (direction)
+ return road
+ ? t('Merge {direction} onto {road}', values)
+ : t('Merge {direction}', values);
+ return road
+ ? t('Merge onto {road}', values)
+ : t('Merge', { context: 'maneuver' });
+ case 'on ramp':
+ if (direction)
+ return road
+ ? t('Take the ramp on the {direction} onto {road}', values)
+ : t('Take the ramp on the {direction}', values);
+ return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
+ case 'off ramp':
+ if (direction)
+ return road
+ ? t('Take the exit on the {direction} onto {road}', values)
+ : t('Take the exit on the {direction}', values);
+ return road ? t('Take the exit onto {road}', values) : t('Take the exit');
+ case 'roundabout':
+ case 'rotary':
+ if (Number.isInteger(step.exit) && step.exit >= 1) {
+ const exit = exitOrdinal(step.exit);
+ return road
+ ? t('At the roundabout, take the {exit} exit onto {road}', {
+ exit,
+ road,
+ })
+ : t('At the roundabout, take the {exit} exit', { exit });
+ }
+ return road
+ ? t('Enter the roundabout onto {road}', values)
+ : t('Enter the roundabout');
+ case 'roundabout turn':
+ if (direction)
+ return road
+ ? t('At the roundabout, turn {direction} onto {road}', values)
+ : t('At the roundabout, turn {direction}', values);
+ return road
+ ? t('At the roundabout, continue onto {road}', values)
+ : t('At the roundabout, continue');
+ case 'exit roundabout':
+ case 'exit rotary':
+ return road
+ ? t('Exit the roundabout onto {road}', values)
+ : t('Exit the roundabout');
+ case 'use lane':
+ if (direction)
+ return road
+ ? t('Use the {direction} lane onto {road}', values)
+ : t('Use the {direction} lane', values);
+ return road
+ ? t('Continue onto {road}', values)
+ : t('Continue', { context: 'maneuver' });
+ default:
+ return road
+ ? t('Continue onto {road}', values)
+ : t('Continue', { context: 'maneuver' });
+ }
+}
+
// ---------------------------------------------------------------------------
// Pure helpers (exported for tests)
// ---------------------------------------------------------------------------
@@ -162,7 +373,7 @@ export function directionsRowControls(state) {
});
chips.push({
id: 'clear',
- label: t('CLEAR'),
+ label: t('CLEAR', { context: 'action' }),
disabled: !a && !b && !route,
state: 'idle',
title: t('Remove the route and both markers'),
@@ -285,8 +496,8 @@ export function directionsStepList({
const items = steps.map((step, index) => ({
id: `step-${index}`,
ordinal: index + 1,
- lead: formatRouteDistance(step.distanceM) || '—',
- text: step.instruction,
+ lead: routeDistanceText(step.distanceM) || '—',
+ text: directionsInstruction(step),
active: index === active,
current: Number.isInteger(flightStep) && index === flightStep,
params: { step: index },
@@ -382,8 +593,8 @@ export function directionsStats(state) {
const spec = DIRECTIONS_MODES[mode];
const word = spec ? t(spec.word) : mode;
const values = {
- distance: formatRouteDistance(route.distanceM),
- duration: formatRouteDuration(route.durationS),
+ distance: routeDistanceText(route.distanceM),
+ duration: routeDurationText(route.durationS),
mode: word,
count: route.steps.length,
};
@@ -428,8 +639,8 @@ export function directionsStepCopy(steps, index) {
const step = steps[index];
const details = [];
const leg = [];
- if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
- if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
+ if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
+ if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
const position = { step: index + 1, total: steps.length };
details.push(
leg.length
@@ -441,8 +652,10 @@ export function directionsStepCopy(steps, index) {
);
const next = steps[index + 1];
if (next)
- details.push(t('Then: {instruction}', { instruction: next.instruction }));
- return { title: step.instruction, details };
+ details.push(
+ t('Then: {instruction}', { instruction: directionsInstruction(next) }),
+ );
+ return { title: directionsInstruction(step), details };
}
/**
diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js
index fe7e9ef31..5fc0cdcb6 100644
--- a/src/layers/flights/tracking.js
+++ b/src/layers/flights/tracking.js
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
-import { t } from '../../i18n/index.js';
+import { formatNumber, t } from '../../i18n/index.js';
+import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import {
@@ -752,8 +753,15 @@ export function createTracking({
// the tail number rather than raw hex.
const cs = parts.queries._contactLabel(icao24, info);
const altFt = Math.round((info.altitude || 0) * 3.28084);
- const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
- const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
+ // Flight levels are an aviation code; feet and knots read in the active
+ // language ("0 фт · 1 уз").
+ const quantity = (value, unit) =>
+ `${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
+ const fl =
+ altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
+ const spd = info.velocity
+ ? quantity(Math.round(info.velocity * 1.944), 'kts')
+ : '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('STALE')
diff --git a/src/layers/radio/model.js b/src/layers/radio/model.js
index f969a63fe..182d7c907 100644
--- a/src/layers/radio/model.js
+++ b/src/layers/radio/model.js
@@ -1,8 +1,30 @@
import {
RADIO_CATEGORY_COLORS,
- RADIO_CLUSTER_LABELS,
RADIO_MARKER_CATEGORY_ORDER,
} from './policy.js';
+import { t } from '../../i18n/index.js';
+
+/** Short uppercase category word on a cluster badge, in the page language. */
+function radioClusterLabel(categoryId) {
+ switch (categoryId) {
+ case 'news':
+ return t('NEWS', { context: 'radio' });
+ case 'talk':
+ return t('TALK', { context: 'radio' });
+ case 'weather':
+ return t('WEATHER', { context: 'radio' });
+ case 'public-safety':
+ return t('SAFETY', { context: 'radio' });
+ case 'aviation-marine':
+ return t('AIR / SEA', { context: 'radio' });
+ case 'traffic-transit':
+ return t('TRANSIT', { context: 'radio' });
+ case 'music':
+ return t('MUSIC', { context: 'radio' });
+ default:
+ return t('OTHER', { context: 'radio' });
+ }
+}
export function createModel({ state: layerState, services, parts, source }) {
/** Return the shared CSS color for a canonical or detected-genre category. */
@@ -18,7 +40,7 @@ export function createModel({ state: layerState, services, parts, source }) {
function radioClusterBadgeText(categoryId = 'other', count = 0) {
const normalized = String(categoryId || 'other');
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
- const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
+ const label = radioClusterLabel(canonical);
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
return `${stationCount} ${label}`;
}
diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js
index 21d987473..16ad23893 100644
--- a/src/layers/radio/policy.js
+++ b/src/layers/radio/policy.js
@@ -127,17 +127,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
other: '#9aa7b3',
});
-export const RADIO_CLUSTER_LABELS = Object.freeze({
- news: 'NEWS',
- talk: 'TALK',
- weather: 'WEATHER',
- 'public-safety': 'SAFETY',
- 'aviation-marine': 'AIR / SEA',
- 'traffic-transit': 'TRANSIT',
- music: 'MUSIC',
- other: 'OTHER',
-});
-
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
'news',
'public-safety',
diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js
new file mode 100644
index 000000000..099a2c3ff
--- /dev/null
+++ b/src/layers/recentImagery/display.js
@@ -0,0 +1,41 @@
+/**
+ * Recent Imagery text in the page's language: days, metres and the focused
+ * day's readout. The model (`model.js`) stays English and i18n-free because AI
+ * tool services import it outside the browser; this module is page-only.
+ */
+import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
+import { formatQuantity } from '../../ui/unitLabels.js';
+import { dayTime } from './model.js';
+
+const SHORT_DAY = Object.freeze({
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+});
+const LONG_DAY = Object.freeze({ ...SHORT_DAY, year: 'numeric' });
+
+/** `Sep 18` / `18 сент.` for a `YYYY-MM-DD` day, in the page's language. */
+export function displayDay(day, options = SHORT_DAY) {
+ const time = dayTime(day);
+ return time === null ? '' : formatDate(time, options);
+}
+
+/** `30 m` / `30 м`, number and unit kept together. */
+export const displayMetres = (value) => formatQuantity(value, 'm');
+
+/** The readout's words in the page's language; the model keeps them English. */
+export const READOUT_WORDS = Object.freeze({
+ date: (day) => displayDay(day, LONG_DAY),
+ daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
+ sensor: (sensor) => t(sensor),
+ metres: displayMetres,
+ overview: () => t('overview'),
+ cloud: (cloud) => {
+ if (!cloud) return t('cloud unknown');
+ const min = Math.round(cloud.min);
+ const max = Math.round(cloud.max);
+ return min === max
+ ? t('{percent}% scene cloud', { percent: min })
+ : t('{min}–{max}% scene cloud', { min, max });
+ },
+});
diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js
index 393502095..daf9a917d 100644
--- a/src/layers/recentImagery/index.js
+++ b/src/layers/recentImagery/index.js
@@ -28,12 +28,12 @@ import {
parseCandidateKey,
quantizeBox,
rankLatest,
- shortDay,
utcDay,
validateBox,
viewTooLargeMessage,
} from './model.js';
-import { t } from '../../i18n/index.js';
+import { msg, t } from '../../i18n/index.js';
+import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
/**
* Quiet time after the last arrow key before the focused day previews, so a
@@ -48,9 +48,10 @@ export const SPLIT_PUBLISH_MS = 400;
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
const COMPARISON_IN_USE = 'Comparison in use by another scene';
-const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
-const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
-const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
+// Kept English in the snapshot (AI tools read it); the card translates them.
+const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
+const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
+const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
/** ZOOM IN's flight time, in seconds. */
const ZOOM_FIT_SECONDS = 1.2;
const DEFAULT_SPLIT = 0.5;
@@ -774,7 +775,7 @@ export function createRecentImageryLayer({
key: key || null,
candidate: candidateFor(key),
label: parsed
- ? `${shortDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${PRODUCTS[parsed.product].resolutionM} m`
+ ? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
: null,
sourceOff,
drapable: drapable(candidateFor(key)),
@@ -1234,7 +1235,7 @@ export function createRecentImageryLayer({
_alpha = 1;
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
}
- _notice = 'Box and images cleared';
+ _notice = msg('Box and images cleared');
syncRender();
publish([...BOX_EDGES, 'a', 'b', 'split']);
notify();
@@ -1283,7 +1284,9 @@ export function createRecentImageryLayer({
candidates,
focusIndex,
focus,
- readout: focus ? formatCandidateReadout(focus, now()) : '',
+ readout: focus
+ ? formatCandidateReadout(focus, now(), READOUT_WORDS)
+ : '',
recommended: ranked.candidate
? { key: ranked.candidate.key, reason: ranked.reason }
: null,
diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs
index 7675d0f3a..7d8c70bd3 100644
--- a/src/layers/recentImagery/index.test.mjs
+++ b/src/layers/recentImagery/index.test.mjs
@@ -228,7 +228,7 @@ test('a valid box searches and previews the START HERE day alone in IMAGE mode',
assert.equal(snapshot.focus.key, S18, 'focus starts on it');
assert.equal(snapshot.preview.key, S18);
assert.equal(snapshot.preview.slot, 'a');
- assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30 m');
+ assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(snapshot.candidates[1].preview, true);
const none = {
key: null,
diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js
index f0fc638b0..26c26ecf9 100644
--- a/src/layers/recentImagery/model.js
+++ b/src/layers/recentImagery/model.js
@@ -36,7 +36,7 @@ export const PRODUCTS = Object.freeze({
label: 'HLS S30', // i18n-ignore: product code
name: msg('Sentinel-2'),
source: 'hls',
- sensor: 'Sentinel-2 via HLS',
+ sensor: msg('Sentinel-2 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -48,7 +48,7 @@ export const PRODUCTS = Object.freeze({
label: 'HLS L30', // i18n-ignore: product code
name: msg('Landsat 8/9'),
source: 'hls',
- sensor: 'Landsat 8/9 via HLS',
+ sensor: msg('Landsat 8/9 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -266,6 +266,11 @@ export function utcDay(value) {
: null;
}
+/** UTC midnight of a `YYYY-MM-DD` day in epoch ms, for locale formatting; null otherwise. */
+export function dayTime(day) {
+ return isValidDay(day) ? Date.parse(`${day}T00:00:00Z`) : null;
+}
+
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
@@ -483,15 +488,14 @@ export function rankLatest(
return { candidate: null, reason: null, certain };
}
-function daysAgoLabel(day, now) {
+/** Whole UTC days from `day` to the day of `now`; null when either is invalid. */
+function daysBetween(day, now) {
const today = utcDay(now);
if (!today) return null;
- const delta = Math.round(
+ return Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
- if (delta <= 0) return 'today';
- return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
@@ -501,18 +505,40 @@ function cloudLabel(cloud) {
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
+/**
+ * The words of the readout. The page passes its own (translated, locale
+ * formatted) set; this module stays free of i18n because AI tool services
+ * import it outside the browser.
+ */
+const READOUT_WORDS = Object.freeze({
+ /** `Sep 18, 2026` for a valid `YYYY-MM-DD` day. */
+ date: (day) => {
+ const [y, m, d] = day.split('-').map(Number);
+ return `${MONTHS[m - 1]} ${d}, ${y}`;
+ },
+ /** Age in whole UTC days, 0 or less for today. */
+ daysAgo: (delta) =>
+ delta <= 0 ? 'today' : delta === 1 ? 'yesterday' : `${delta} days ago`,
+ sensor: (sensor) => sensor,
+ metres: (value) => `${value} m`,
+ overview: () => 'overview',
+ /** `{ min, max }` scene cloud in percent, or null. */
+ cloud: cloudLabel,
+});
+
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
+ * @param {Partial} [words] the page's localized words
* @returns {string}
*/
-export function formatCandidateReadout(candidate, now = new Date()) {
+export function formatCandidateReadout(candidate, now = new Date(), words) {
const product = PRODUCTS[candidate?.product];
- if (!product || !candidate?.day) return '';
- const [y, m, d] = candidate.day.split('-').map(Number);
- let when = `${MONTHS[m - 1]} ${d}, ${y}`;
+ if (!product || !isValidDay(candidate?.day)) return '';
+ const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
+ let when = say.date(candidate.day);
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
@@ -522,11 +548,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
: ` ${start}Z`;
}
const parts = [when];
- const ago = daysAgoLabel(candidate.day, now);
- if (ago) parts.push(ago);
- parts.push(product.sensor, `${product.resolutionM} m`);
- if (product.overview) parts.push('overview');
- parts.push(cloudLabel(candidate.cloud));
+ const delta = daysBetween(candidate.day, now);
+ if (delta !== null) parts.push(say.daysAgo(delta));
+ parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
+ if (product.overview) parts.push(say.overview());
+ parts.push(say.cloud(candidate.cloud));
return parts.join(' · ');
}
diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js
index d171539bf..5212a1dd7 100644
--- a/src/layers/weather/index.js
+++ b/src/layers/weather/index.js
@@ -8,6 +8,7 @@ import {
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
import { t } from '../../i18n/index.js';
+import { formatScaleLabel } from '../../ui/unitLabels.js';
const STOPS = [
[10, '#00ecec'],
@@ -692,9 +693,11 @@ export function createWeatherLayer({
color,
blurb: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
- value: label,
+ value: formatScaleLabel(label),
})
- : t('{value} dBZ radar reflectivity', { value: label }),
+ : t('{value} dBZ radar reflectivity', {
+ value: formatScaleLabel(label),
+ }),
}))
: [],
info: hostHidden
diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js
index c23e16ac8..d7bb01902 100644
--- a/src/layers/wind/index.js
+++ b/src/layers/wind/index.js
@@ -12,6 +12,7 @@ import {
WIND_UNITS,
} from './inspection.js';
import { t } from '../../i18n/index.js';
+import { unitLabel } from '../../ui/unitLabels.js';
/** Format a forecast timestamp explicitly in UTC. */
export function formatWindValidTime(value) {
@@ -358,7 +359,7 @@ export function createWindLayer({
: []
).map((value) => ({
id: `units-${value}`,
- label: value,
+ label: unitLabel(value),
active: units === value,
params: { units: value },
title: t('Wind speed units'),
@@ -382,7 +383,7 @@ export function createWindLayer({
info: `${t('{model} forecast · {field} ({unit})', {
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
field: label,
- unit: legendUnit,
+ unit: unitLabel(legendUnit),
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
infoTitle: t(
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
diff --git a/src/layers/wind/presentation.js b/src/layers/wind/presentation.js
index 799a69c94..941cff5e1 100644
--- a/src/layers/wind/presentation.js
+++ b/src/layers/wind/presentation.js
@@ -1,5 +1,6 @@
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
import { t } from '../../i18n/index.js';
+import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
/** Reformat a captured sample; changing units never samples the map again. */
export function formatWindReading(reading, units) {
@@ -20,20 +21,42 @@ export function formatWindReading(reading, units) {
export function windUnitChips(units) {
return Object.keys(WIND_UNITS).map((value) => ({
id: `units-${value}`,
- label: value,
+ label: unitLabel(value),
active: units === value,
params: { units: value },
title: t('Wind speed units'),
}));
}
+/** formatWindSpeed's value in the active language: "22,0 км/ч". */
+function displaySpeed(speed, units) {
+ const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
+ return formatQuantity(speed * WIND_UNITS[unit], unit, {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+}
+
+/** "1012.4 hPa" / "-3.0 °C" from the English reading, in the active language. */
+function displayScalar(reading) {
+ if (reading.scalarKind === 'speed' && Number.isFinite(reading.speed))
+ return displaySpeed(reading.speed, reading.units);
+ const match = /^(-?\d+(?:\.\d+)?) (°C|hPa)$/.exec(reading.scalarValue);
+ if (!match) return reading.scalarValue;
+ const decimals = match[1].split('.')[1]?.length || 0;
+ return formatQuantity(Number(match[1]), match[2], {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+}
+
/** The wind line in the active language; the reading itself stays English. */
function windText(reading) {
if (!Number.isFinite(reading.speed))
return reading.wind === 'Aim the map center at Earth'
? t('Aim the map center at Earth')
: reading.wind;
- const speed = formatWindSpeed(reading.speed, reading.units);
+ const speed = displaySpeed(reading.speed, reading.units);
return reading.from === 'Calm'
? `${speed}${t(' · calm')}`
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
@@ -64,7 +87,7 @@ export function windReadingResult(reading) {
? [
{
id: 'scalar',
- text: `${t(reading.scalarLabel)} · ${reading.scalarValue}`,
+ text: `${t(reading.scalarLabel)} · ${displayScalar(reading)}`,
},
]
: []),
diff --git a/src/layers/wind/presentation.test.mjs b/src/layers/wind/presentation.test.mjs
index e6d22ffb9..99b44af71 100644
--- a/src/layers/wind/presentation.test.mjs
+++ b/src/layers/wind/presentation.test.mjs
@@ -2,7 +2,16 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { formatWindReading, windReadingResult } from './presentation.js';
-const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
+const sample = {
+ speed: 5,
+ from: 'SW',
+ coordinates: '41.9°N · 87.6°W',
+ model: 'NOAA GFS',
+ validTime: '2026-09-21 12:00 UTC',
+ scalarLabel: 'Air temperature · 2 m',
+ scalarValue: '12 °C',
+ explanation: 'Interpolated model forecast.',
+};
test('captured reading reformats without changing its sample and produces a portable result block', () => {
const reading = formatWindReading(sample, 'mph');
assert.equal(reading.wind, '11.2 mph from SW');
@@ -11,12 +20,30 @@ test('captured reading reformats without changing its sample and produces a port
assert.equal(sample.units, undefined);
const section = windReadingResult(reading);
assert.equal(section.id, 'reading');
- assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
- assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
- assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
+ assert.equal(
+ section.lines.find(({ id }) => id === 'scalar').text,
+ 'Air temperature · 2 m · 12\u00a0°C',
+ );
+ assert.equal(
+ section.lines.find(({ id }) => id === 'wind').text,
+ '11.2\u00a0mph from SW',
+ );
+ assert.equal(
+ section.lines.find(({ id }) => id === 'meta').text,
+ 'NOAA GFS · valid 09-21 12:00 UTC',
+ );
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
assert.deepEqual(section.clear.params, { inspect: false });
- assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
+ assert.equal(
+ formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind,
+ '5.0 m/s · calm',
+ );
assert.equal(formatWindReading(null, 'km/h'), null);
- assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
+ assert.equal(
+ windReadingResult({
+ coordinates: 'No surface reading',
+ wind: 'Unavailable',
+ }).lines.some(({ id }) => id === 'scalar'),
+ false,
+ );
});
diff --git a/src/scenes/director.js b/src/scenes/director.js
index 5c9ebf1d2..e134138a8 100644
--- a/src/scenes/director.js
+++ b/src/scenes/director.js
@@ -23,6 +23,8 @@ import * as Cesium from 'cesium';
import {
SCENE_APPEND_RECIPES,
SCENE_RECIPES,
+ displaySceneTitle,
+ displayShotTitle,
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
@@ -66,19 +68,7 @@ import {
// project titles, and voice-matching titles all stay English (keep-English
// boundary + persisted data); only the presentation of an UNRENAMED built-in
// localizes, so an operator's renamed scene is never masked by the catalog.
-// Recipe titles are marked with msg() in recipes.js.
-
-/**
- * Display title for a scene: the localized recipe name while the stored title
- * still matches the built-in verbatim, the stored title otherwise.
- * @param {{id?: string, title?: string}} scene
- * @returns {string}
- */
-function displaySceneTitle(scene) {
- const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
- if (!builtin?.title) return scene?.title || '';
- return scene.title === builtin.title ? t(builtin.title) : scene.title;
-}
+// Recipe and built-in shot titles are marked with msg() in recipes.js.
/** @constant {string} localStorage key for the serialized project */
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
@@ -390,8 +380,8 @@ export class SceneDirector {
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded')
this._presentation.status = t('Loaded: {scene} / {shot}', {
- scene: scene.title,
- shot: shot.title,
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
});
this._publish({
type,
@@ -412,10 +402,15 @@ export class SceneDirector {
subscribe: (listener) => this.subscribe(listener),
read: () => ({
// Scene titles surface in the panel through SceneControls; built-in
- // recipe titles display their localized name until renamed by the user.
+ // recipe and shot titles display their localized name until renamed
+ // by the user. Shots keep the stored title for editing and matching.
scenes: this._project.scenes.map((scene) => ({
...scene,
title: displaySceneTitle(scene),
+ shots: scene.shots.map((shot) => ({
+ ...shot,
+ displayTitle: displayShotTitle(shot.title),
+ })),
})),
selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId,
@@ -990,8 +985,8 @@ export class SceneDirector {
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('Captured: {scene} / {shot}', {
- scene: scene.title,
- shot: shot.title,
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
}),
);
}
@@ -1023,8 +1018,8 @@ export class SceneDirector {
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('Updated: {scene} / {shot}', {
- scene: scene.title,
- shot: shot.title,
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
}),
);
}
@@ -1134,7 +1129,7 @@ export class SceneDirector {
if (!token.cancelled)
this._updateStatus(
t('Could not leave scene: {scene}', {
- scene: previousScene.title,
+ scene: displaySceneTitle(previousScene),
}),
);
return;
@@ -1187,7 +1182,10 @@ export class SceneDirector {
});
if (this._loadAbort === controller) this._loadAbort = null;
this._updateStatus(
- t('Seeked: {scene} / {shot}', { scene: scene.title, shot: shot.title }),
+ t('Seeked: {scene} / {shot}', {
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
+ }),
);
this._activateInteractions(scene, shot);
return { started: true, shotId };
@@ -1551,7 +1549,10 @@ export class SceneDirector {
seeking: true,
});
this._updateStatus(
- t('Seeked: {scene} / {shot}', { scene: scene.title, shot: shot.title }),
+ t('Seeked: {scene} / {shot}', {
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
+ }),
);
return true;
}
diff --git a/src/scenes/playbackAdapter.js b/src/scenes/playbackAdapter.js
index 48988e1be..c1d82bddc 100644
--- a/src/scenes/playbackAdapter.js
+++ b/src/scenes/playbackAdapter.js
@@ -1,5 +1,6 @@
/** Connect the portable Director runner to the existing scene presentation. */
import { t } from '../i18n/index.js';
+import { displaySceneTitle, displayShotTitle } from './recipes.js';
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
let timing;
@@ -19,8 +20,8 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
t('Running {index}/{total}: {scene} / {shot}', {
index: index + 1,
total,
- scene: scene.title,
- shot: shot.title,
+ scene: displaySceneTitle(scene),
+ shot: displayShotTitle(shot.title),
}),
);
director._logEvent('shot_start', {
diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js
index f938f28f3..4a330d142 100644
--- a/src/scenes/recipes.js
+++ b/src/scenes/recipes.js
@@ -4,7 +4,7 @@
*/
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
-import { msg } from '../i18n/index.js';
+import { msg, t } from '../i18n/index.js';
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
id: 'bhote-koshi-nepal-evidence-pack',
@@ -1011,3 +1011,53 @@ export function getSceneRecipeById(id) {
export function getSceneAppendRecipeById(id) {
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
}
+
+// Built-in shot titles stay English in recipes and saved projects (upgrades
+// and bindings match them verbatim). These are their display names; a title
+// the user typed shows as typed. Place names inside them stay Latin.
+const BUILTIN_SHOT_TITLES = new Set([
+ msg('Global Incident Context'),
+ msg('Nepal-Focused Globe Rotation'),
+ msg('Bhote Koshi Regional Approach'),
+ msg('Bhote Koshi Nearby Cities'),
+ msg('Bhote Koshi Incident Corridor'),
+ msg('Bhote Koshi Flood Path'),
+ msg('Bhote Koshi Corridor Overview'),
+ msg('Bhote Koshi Upper Valley'),
+ msg('Upper Valley Collapse'),
+ msg('Debris-Dammed Lake'),
+ msg('Nepal-China Border Gate'),
+ msg('Timure Evidence Cluster'),
+ msg('Syabru Besi Passage'),
+ msg('Bidur / Trishuli Consequence'),
+ msg('Second landslide'),
+ msg('Mailung / Upper Trishuli bridge'),
+ msg('Dandagaun viewpoint restaurant'),
+ msg('Devighat / Taadi Khola Bridge'),
+ msg('Final view'),
+]);
+
+/**
+ * Display title for a scene: the localized recipe name while the stored title
+ * still matches the built-in verbatim, the stored title otherwise.
+ * @param {{id?: string, title?: string}} scene
+ * @returns {string}
+ */
+export function displaySceneTitle(scene) {
+ const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
+ if (!builtin?.title) return scene?.title || '';
+ return scene.title === builtin.title ? t(builtin.title) : scene.title;
+}
+
+/**
+ * Display title for a shot: built-in titles and the numbered "Shot N"
+ * fallback localize; any other title shows as stored.
+ * @param {string} title
+ * @returns {string}
+ */
+export function displayShotTitle(title) {
+ const text = typeof title === 'string' ? title : '';
+ if (BUILTIN_SHOT_TITLES.has(text)) return t(text);
+ const numbered = /^Shot (\d+)$/.exec(text);
+ return numbered ? t('Shot {n}', { n: Number(numbered[1]) }) : text;
+}
diff --git a/src/scenes/shotPresentation.js b/src/scenes/shotPresentation.js
index 921aa2709..f98953d0e 100644
--- a/src/scenes/shotPresentation.js
+++ b/src/scenes/shotPresentation.js
@@ -1,3 +1,5 @@
+import { displaySceneTitle, displayShotTitle } from './recipes.js';
+
const DEFAULT_SHOT_DURATION_SEC = 4;
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const deepClone = (value) => JSON.parse(JSON.stringify(value));
@@ -63,8 +65,10 @@ export function layerStatesForShot(
state.params.sceneContext = {
sceneId: scene.id,
sceneTitle: scene.title,
+ sceneDisplayTitle: displaySceneTitle(scene),
shotId: shot.id,
shotTitle: shot.title,
+ shotDisplayTitle: displayShotTitle(shot.title),
shotIndex,
shotCount: scene.shots.length,
durationSec: shot.durationSec || DEFAULT_SHOT_DURATION_SEC,
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 9b66e839a..9d2db757c 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -571,6 +571,7 @@ export class StyleManager extends ShellFacade {
this._initMapStackControl();
this._initPanelChrome();
this._initLocaleSelector();
+ this._sizeDisplayLabelColumn();
this._applyRuntimeStaticHeaderText();
this._initLeftPanelAdaptiveLayout();
this._initRightPanelAdaptiveLayout();
@@ -735,6 +736,30 @@ export class StyleManager extends ShellFacade {
}
}
+ /**
+ * Gives every Display-panel row label one shared column, sized in
+ * characters to the longest label of the active language, so the slider
+ * tracks of separate rows start at the same x. The labels are monospace and
+ * often hidden (collapsed rows have no layout), so this counts characters
+ * instead of measuring; the CSS turns the count into a width.
+ * @returns {void}
+ */
+ _sizeDisplayLabelColumn() {
+ const labels = document.querySelectorAll(
+ '#pp-toggles .pp-slider-row > .pp-slider-mini-label',
+ );
+ let longest = 0;
+ for (const label of labels) {
+ longest = Math.max(longest, label.textContent.trim().length);
+ }
+ if (longest > 0) {
+ document.documentElement.style.setProperty(
+ '--pp-label-chars',
+ String(longest),
+ );
+ }
+ }
+
/**
* Rewrites a header's trailing text node in place, preserving the leading
* dock-label-icon span that a textContent write would destroy (the phase-2
diff --git a/src/ui/cctvCalibration.js b/src/ui/cctvCalibration.js
index 98b553acf..b2d6dd1a2 100644
--- a/src/ui/cctvCalibration.js
+++ b/src/ui/cctvCalibration.js
@@ -1,4 +1,5 @@
-import { t, msg } from '../i18n/index.js';
+import { formatNumber, t, msg } from '../i18n/index.js';
+import { formatQuantity } from './unitLabels.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -66,6 +67,21 @@ const CCTV_CAL_FIELDS = {
},
};
+/** Chip text for one pose value: locale decimals, translated metre unit. */
+function calValueText(field, value) {
+ const options = {
+ minimumFractionDigits: field.decimals,
+ maximumFractionDigits: field.decimals,
+ };
+ if (field.unit === 'm')
+ return `${t(field.label)} ${formatQuantity(Number(value), 'm', options)}`;
+ const number = formatNumber(Number(value), {
+ useGrouping: false,
+ ...options,
+ });
+ return `${t(field.label)} ${number}${field.unit}`;
+}
+
export function _activeCctvCameraId() {
return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
}
@@ -176,7 +192,7 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
if (!field) continue;
const value = canCalibrate ? field.get(activeCamera) : null;
chip.textContent = Number.isFinite(value)
- ? `${t(field.label)} ${Number(value).toFixed(field.decimals)}${field.unit}`
+ ? calValueText(field, value)
: `${t(field.label)} --`;
chip.disabled = !canCalibrate;
}
diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js
index 211f9992b..afedb64fa 100644
--- a/src/ui/cctvFrames.js
+++ b/src/ui/cctvFrames.js
@@ -1,4 +1,80 @@
-import { t } from '../i18n/index.js';
+import { msg, t } from '../i18n/index.js';
+
+/**
+ * Display text for the CCTV source health the server reports
+ * (server/providers/cctv.js `setHealth`). The server keeps its English codes
+ * and messages; the panel translates them where it renders.
+ */
+
+const SOURCE_KIND_LABELS = Object.freeze({
+ snapshot: msg('SNAPSHOT'),
+ live: msg('LIVE'),
+ fallback: msg('FALLBACK'),
+ upstream: msg('UPSTREAM'),
+ synthetic: msg('SYNTHETIC'),
+ configured: msg('CONFIGURED'),
+ seed: msg('SEED'),
+ image: msg('IMAGE'),
+ unknown: msg('UNKNOWN'),
+});
+
+const SOURCE_STATUS_LABELS = Object.freeze({
+ degraded: msg('DEGRADED'),
+ error: msg('ERROR'),
+ unknown: msg('UNKNOWN'),
+});
+
+const SOURCE_MESSAGES = Object.freeze({
+ 'Live HLS connected': msg('Live HLS connected'),
+ 'Live HLS unavailable': msg('Live HLS unavailable'),
+ 'No stream URL configured': msg('No stream URL configured'),
+ 'Live stream connected': msg('Live stream connected'),
+ 'Snapshot feed connected': msg('Snapshot feed connected'),
+ 'Media fetch failed': msg('Media fetch failed'),
+ 'Upstream snapshot active': msg('Upstream snapshot active'),
+ 'Fallback Street View frame': msg('Fallback Street View frame'),
+ 'Upstream unavailable': msg('Upstream unavailable'),
+ 'No source configured': msg('No source configured'),
+});
+
+const SOURCE_LABELS = Object.freeze({
+ 'Configured source': msg('Configured source'),
+ 'Configured Source': msg('Configured source'),
+ 'Synthetic fallback': msg('Synthetic fallback'),
+});
+
+/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
+export function cctvSourceKindLabel(kind) {
+ const key = String(kind || 'unknown').toLowerCase();
+ return SOURCE_KIND_LABELS[key]
+ ? t(SOURCE_KIND_LABELS[key])
+ : key.toUpperCase();
+}
+
+/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
+export function cctvSourceStatusLabel(status) {
+ const key = String(status || 'unknown').toLowerCase();
+ return SOURCE_STATUS_LABELS[key]
+ ? t(SOURCE_STATUS_LABELS[key])
+ : key.toUpperCase();
+}
+
+/** A server health message in the page language; unknown text passes through. */
+export function cctvSourceMessageText(message) {
+ const text = String(message || '');
+ if (SOURCE_MESSAGES[text]) return t(SOURCE_MESSAGES[text]);
+ const http = /^Upstream HTTP (\d{3})$/.exec(text);
+ if (http) return t('Upstream HTTP {status}', { status: http[1] });
+ const media = /^Unexpected media type (.+)$/.exec(text);
+ if (media) return t('Unexpected media type {type}', { type: media[1] });
+ return text;
+}
+
+/** A generic provider label from the server; real provider names pass through. */
+export function cctvSourceLabelText(label) {
+ const text = String(label || '');
+ return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
+}
export function _clearCctvFrame() {
this._cctvFrameRequestToken += 1;
@@ -104,10 +180,10 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
this._cctvSourceBadge.dataset.frameState = 'error';
return;
}
- const kind = String(
+ const kind = cctvSourceKindLabel(
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
- ).toUpperCase();
- const status = String(activeCamera.sourceStatus || 'unknown').toUpperCase();
+ );
+ const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
this._cctvSourceBadge.dataset.frameState = 'ready';
}
diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js
index 4eb37d257..3e738e109 100644
--- a/src/ui/cctvPresentation.js
+++ b/src/ui/cctvPresentation.js
@@ -1,4 +1,5 @@
import { t, tn } from '../i18n/index.js';
+import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) {
@@ -148,12 +149,13 @@ export function _renderCctvState(state) {
if (this._cctvMeta) {
if (activeCamera) {
- const provider =
+ const provider = cctvSourceLabelText(
activeCamera.sourceLabel ||
- activeCamera.provider ||
- 'Configured Source';
+ activeCamera.provider ||
+ 'Configured Source',
+ );
const statusMsg = activeCamera.sourceMessage
- ? ` · ${activeCamera.sourceMessage}`
+ ? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
: '';
// A partner-supplied feed inside a pack names its owner here.
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js
index e048d16fb..974b32889 100644
--- a/src/ui/cockpitBriefing.js
+++ b/src/ui/cockpitBriefing.js
@@ -8,6 +8,7 @@ import {
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge,
+ formatCockpitCoordinate,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
@@ -54,7 +55,9 @@ export function showBriefPage(index, { manual = false } = {}) {
if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource)
- this.briefSource.textContent = t(page.sourceKey ?? page.source);
+ this.briefSource.textContent = page.sourceKey
+ ? t(page.sourceKey, { source: page.sourceName })
+ : page.source;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
@@ -134,8 +137,8 @@ export function updateLocalPosition(info) {
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
return;
}
- const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
- const lon = `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`;
+ const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
+ const lon = formatCockpitCoordinate(info.longitude, 'E', 'W');
this.localCoordinates.textContent = `${lat} · ${lon}`;
}
diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js
index 0e9579719..9d177a078 100644
--- a/src/ui/cockpitContext.js
+++ b/src/ui/cockpitContext.js
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
-import { t, tn } from '../i18n/index.js';
+import { formatNumber, t, tn } from '../i18n/index.js';
+import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
formatCockpitContextScope,
@@ -8,6 +9,17 @@ import {
resolveCockpitContextReadout,
} from '../cockpitMath.js';
+/** The Context scope line in the active language (see cockpitMath.js). */
+const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
+ window: ({ label, radiusKm }) =>
+ t('{label} · {radius} AIR/SEA WINDOW', {
+ label,
+ radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
+ }),
+ installations: ({ scope, coverage }) =>
+ t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
+});
+
export function updateContext(info, heading) {
if (!this.context) return;
const snapshot =
@@ -37,6 +49,7 @@ export function updateContext(info, heading) {
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
+ LOCALIZED_CONTEXT_SCOPE,
);
}
// Navigation stays wired in every state — the operator must always be able
diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js
index 7c778d977..eb48ab817 100644
--- a/src/ui/cockpitInstruments.js
+++ b/src/ui/cockpitInstruments.js
@@ -20,6 +20,8 @@ import {
} from '../cockpitMath.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
+ compassPointLabel,
+ formatCockpitCoordinate,
setCockpitRollingValue,
} from './cockpitPresentation.js';
@@ -110,7 +112,7 @@ export function updateHud(
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
- return `${formatCompassDivision(division)}`;
+ return `${compassPointLabel(formatCompassDivision(division))}`;
})
.join('');
}
@@ -119,10 +121,10 @@ export function updateHud(
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) {
const lat = Number.isFinite(info.latitude)
- ? `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`
+ ? formatCockpitCoordinate(info.latitude, 'N', 'S')
: '--';
const lon = Number.isFinite(info.longitude)
- ? `${Math.abs(info.longitude).toFixed(3)}°${info.longitude >= 0 ? 'E' : 'W'}`
+ ? formatCockpitCoordinate(info.longitude, 'E', 'W')
: '--';
this.position.textContent = `${lat} · ${lon}`;
}
diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js
index 7f3b30efa..9d21934a3 100644
--- a/src/ui/cockpitPresentation.js
+++ b/src/ui/cockpitPresentation.js
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
-import { t, msg } from '../i18n/index.js';
+import { formatNumber, t, msg } from '../i18n/index.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -54,8 +54,9 @@ export const COCKPIT_BRIEF_PAGES = [
id: 'news',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
- // RSS provider attribution is a keep-English boundary string.
- source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
+ // The RSS provider name stays English; the rest of the line translates.
+ sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
+ sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
},
{
id: 'local',
@@ -93,11 +94,43 @@ export function formatCockpitBriefAge(value) {
: t('{count}D AGO', { count: Math.round(hours / 24) });
}
+/** Compass point codes in display order and the label shown for each. */
+const COMPASS_POINT_LABELS = Object.freeze({
+ N: msg('N'),
+ NE: msg('NE'),
+ E: msg('E'),
+ SE: msg('SE'),
+ S: msg('S'),
+ SW: msg('SW'),
+ W: msg('W'),
+ NW: msg('NW'),
+});
+
+/**
+ * The shown label for a compass point code (`N`, `SW`…): С, ЮЗ… in Russian.
+ * Anything else (a degree reading such as `030`) passes through unchanged.
+ */
+export function compassPointLabel(code) {
+ return Object.hasOwn(COMPASS_POINT_LABELS, code)
+ ? t(COMPASS_POINT_LABELS[code])
+ : code;
+}
+
+/** `30.203°N` in the active language: `30,203°С`. */
+export function formatCockpitCoordinate(value, positive, negative) {
+ const amount = formatNumber(Math.abs(value), {
+ useGrouping: false,
+ minimumFractionDigits: 3,
+ maximumFractionDigits: 3,
+ });
+ return `${amount}°${compassPointLabel(value >= 0 ? positive : negative)}`;
+}
+
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
- const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
+ const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
- return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
+ return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
}
export function setCockpitRollingValue(
diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js
index 3b8b7baf4..4c75e2d73 100644
--- a/src/ui/layerPanel.js
+++ b/src/ui/layerPanel.js
@@ -135,6 +135,52 @@ function panelLabel(layer) {
return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : 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'),
+ ].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);
+ return key ? t(key) : source;
+}
+
/**
* Guidance for a control a missing provider key is holding back.
*
@@ -580,7 +626,7 @@ export class LayerPanel {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
- const source = stats.source || layer.source;
+ const source = sourceLabel(stats.source || layer.source);
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
diff --git a/src/ui/localSdrControls.js b/src/ui/localSdrControls.js
index b4845ec00..468360b64 100644
--- a/src/ui/localSdrControls.js
+++ b/src/ui/localSdrControls.js
@@ -3,6 +3,7 @@ import {
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
+ localSdrGainOptionLabel,
} from './localSdrPresentation.js';
const ELEMENT_IDS = Object.freeze({
@@ -77,6 +78,7 @@ export class LocalSdrControls {
this._renderFrame = null;
this._destroyed = false;
if (!this.elements.card) return;
+ this._labelGainOptions();
this._bind();
this._receiverUnsubscribe = receiver.subscribe((state) =>
this._onReceiverState(state),
@@ -95,6 +97,14 @@ export class LocalSdrControls {
return Boolean(this._state?.connected);
}
+ /** Numeric gain options read in the page language; values stay numeric. */
+ _labelGainOptions() {
+ for (const option of this.elements.gain?.options || []) {
+ if (option.value !== 'auto')
+ option.textContent = localSdrGainOptionLabel(option.value);
+ }
+ }
+
_listen(element, type, handler) {
element?.addEventListener(type, handler, {
signal: this._listeners.signal,
diff --git a/src/ui/localSdrPresentation.js b/src/ui/localSdrPresentation.js
index 1066208dd..7b3aabd61 100644
--- a/src/ui/localSdrPresentation.js
+++ b/src/ui/localSdrPresentation.js
@@ -1,5 +1,6 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
import { msg, t, tn } from '../i18n/index.js';
+import { formatQuantity } from './unitLabels.js';
/**
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
@@ -21,6 +22,19 @@ function statusLabel(status) {
return STATUS_LABELS[key] ? t(STATUS_LABELS[key]) : key.toUpperCase();
}
+/**
+ * Text for a numeric gain option: the option value stays the machine value
+ * ("28.0"); only the label follows the locale ("28,0 дБ").
+ */
+export function localSdrGainOptionLabel(value) {
+ const gain = Number(value);
+ if (!Number.isFinite(gain)) return String(value);
+ return formatQuantity(gain, 'dB', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+}
+
function formatGain(gain) {
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
}
diff --git a/src/ui/localSdrPresentation.test.mjs b/src/ui/localSdrPresentation.test.mjs
index d159ae52d..3a5be4565 100644
--- a/src/ui/localSdrPresentation.test.mjs
+++ b/src/ui/localSdrPresentation.test.mjs
@@ -6,7 +6,9 @@ import {
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
+ localSdrGainOptionLabel,
} from './localSdrPresentation.js';
+import { setLocale } from '../i18n/index.js';
const idle = Object.freeze({
webUsbSupported: true,
@@ -153,3 +155,15 @@ test('two feeds on one band are told apart by their ordinal labels', () => {
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
);
});
+
+test('gain option labels follow the page language; values stay numeric', () => {
+ try {
+ setLocale('en');
+ assert.equal(localSdrGainOptionLabel('28.0'), '28.0\u00a0dB');
+ assert.equal(localSdrGainOptionLabel('0.9'), '0.9\u00a0dB');
+ setLocale('ru');
+ assert.match(localSdrGainOptionLabel('28.0'), /^28,0\u00a0\S+$/);
+ } finally {
+ setLocale('en');
+ }
+});
diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js
index 250745119..889c29bbf 100644
--- a/src/ui/radioPresentation.js
+++ b/src/ui/radioPresentation.js
@@ -215,6 +215,12 @@ export function renderRadioState(state) {
: state.audioState === 'paused'
? t('Resume')
: t('Play');
+ // The transport button is narrow: it gets its own short uppercase labels.
+ const playbackButtonLabel = activePlayback
+ ? t('PAUSE')
+ : state.audioState === 'paused'
+ ? t('RESUME')
+ : t('PLAY');
const playTargetWord = selected ? t('selected') : t('nearest');
const playAria = t('{action} {target} radio station', {
action: playbackAction,
@@ -223,7 +229,7 @@ export function renderRadioState(state) {
if (this._radioPlayBtn) {
this._radioPlayBtn.disabled = !interactive || !hasStations;
this._radioPlayBtn.classList.toggle('active', activePlayback);
- this._radioPlayBtn.textContent = playbackAction.toUpperCase();
+ this._radioPlayBtn.textContent = playbackButtonLabel;
this._radioPlayBtn.setAttribute('aria-label', playAria);
}
if (this._contextRadioMiniPlayBtn) {
diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js
index 954261e8a..f9f979a97 100644
--- a/src/ui/railCardBlocks.js
+++ b/src/ui/railCardBlocks.js
@@ -1,6 +1,7 @@
import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
+import { formatScaleLabel, unitLabel } from './unitLabels.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -14,6 +15,13 @@ const order = (parent, nodes) =>
parent.insertBefore(node, parent.children[i] || null);
});
+// "960 — 1050+ гПа": the unit stays with the last number.
+const scaleText = (low, high, units) => {
+ const unit = unitLabel(units);
+ const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
+ return unit ? `${range}\u00a0${unit}` : range;
+};
+
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
@@ -198,7 +206,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
block.scale,
'textContent',
visible
- ? `${legend.labels[0]} — ${legend.labels.at(-1)} ${legend.units || ''}`.trim()
+ ? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
: '',
);
} else if (props.type === 'actions') {
diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js
index be1bbd141..a3bae70e4 100644
--- a/src/ui/recentImagery.js
+++ b/src/ui/recentImagery.js
@@ -8,20 +8,50 @@
* strip and selection bar are this adapter's own keyed DOM. Source toggles
* live on the data-panel row.
*/
-import { t, tn, msg } from '../i18n/index.js';
+import { t, tn, msg, formatNumber } from '../i18n/index.js';
import { createRailCardBlocks } from './railCardBlocks.js';
import { createRailCards } from './railCards.js';
import { createImagerySplit } from './imagerySplit.js';
import {
PRODUCTS,
hhmm,
- shortDay,
wvsSnapshotUrl,
} from '../layers/recentImagery/model.js';
+import { displayDay, displayMetres } from '../layers/recentImagery/display.js';
+import { formatQuantity } from './unitLabels.js';
const PANEL_ID = 'recent-imagery-panel';
const OVERVIEW_MIN_BOX_KM = 25;
const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared');
+
+// The layer keeps its notices and box refusals in English (AI tools read the
+// same snapshot); translate the ones we write, show anything else as it came.
+const LAYER_NOTICES = new Set([
+ ESRI_NOTE,
+ msg('Box and images cleared'),
+ msg('Esri map unavailable · no swipe'),
+ msg('Swipe needs a globe map'),
+ msg('Imagery stays on Esri · CLEAR to use Google 3D'),
+]);
+const TOO_LARGE = /^(Box|View) is ([\d,]+) km wide · limit ([\d,]+) km$/;
+
+function localizeNotice(text) {
+ if (!text) return '';
+ if (LAYER_NOTICES.has(text)) return t(text);
+ const tooLarge = TOO_LARGE.exec(text);
+ if (!tooLarge) return text;
+ const values = {
+ size: formatQuantity(Number(tooLarge[2].replace(/,/g, '')), 'km', {
+ useGrouping: true,
+ }),
+ limit: formatQuantity(Number(tooLarge[3].replace(/,/g, '')), 'km', {
+ useGrouping: true,
+ }),
+ };
+ return tooLarge[1] === 'Box'
+ ? t('Box is {size} wide · limit {limit}', values)
+ : t('View is {size} wide · limit {limit}', values);
+}
const MODE_LABELS = [
['image', msg('IMAGE')],
['basemap', msg('VS BASEMAP')],
@@ -56,17 +86,25 @@ const appearedDocuments = new WeakSet();
const sensorLine = (product) =>
PRODUCTS[product]
- ? `${t(PRODUCTS[product].name)} · ${PRODUCTS[product].resolutionM} m`
+ ? `${t(PRODUCTS[product].name)} · ${displayMetres(PRODUCTS[product].resolutionM)}`
: product || '';
const utcTime = (iso) => (hhmm(iso) ? `${hhmm(iso)}Z` : '');
const longerSideKm = (size) =>
Math.max(Number(size?.width) || 0, Number(size?.height) || 0);
-const kmText = (km) =>
- !Number.isFinite(km)
- ? '?'
- : km >= 100
- ? Math.round(km).toLocaleString('en-US')
- : km.toFixed(1);
+const kmText = (km) => {
+ if (!Number.isFinite(km)) return '?';
+ const digits = km >= 100 ? 0 : 1;
+ return formatNumber(km, {
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
+ });
+};
+
+function coverageText(coverage) {
+ if (coverage === 'full') return t('full', { context: 'coverage' });
+ if (coverage === 'partial') return t('partial', { context: 'coverage' });
+ return t('unknown');
+}
function cloudText(candidate) {
if (candidate.thumbnail?.status === 'empty') return t('no imagery');
@@ -344,7 +382,7 @@ export function createRecentImageryPanel({
},
{
id: 'clear',
- label: t('CLEAR'),
+ label: t('CLEAR', { context: 'action' }),
title: t('Forget the box and its images'),
disabled: !snapshot.box && !snapshot.boxError,
onClick: () => {
@@ -372,8 +410,9 @@ export function createRecentImageryPanel({
// ---- notice -----------------------------------------------------------
function renderNotice() {
const warning = snapshot.boxError || snapshot.error || exportError;
- const text =
- warning || snapshot.notice || (snapshot.borrowedEsri ? t(ESRI_NOTE) : '');
+ const text = localizeNotice(
+ warning || snapshot.notice || (snapshot.borrowedEsri ? ESRI_NOTE : ''),
+ );
set(noticeText, 'textContent', text);
attribute(noticeText, 'title', text);
toggle(notice, 'warn', Boolean(warning));
@@ -416,7 +455,7 @@ export function createRecentImageryPanel({
const entry = cards.get(candidate.key);
if (!entry) return;
const { card } = entry;
- const day = shortDay(candidate.day);
+ const day = displayDay(candidate.day);
const ab = snapshot.mode === 'ab';
set(card, 'id', `ri-card-${index}`);
const focused = index === snapshot.focusIndex;
@@ -735,7 +774,7 @@ export function createRecentImageryPanel({
}),
);
times.push(
- t('Coverage {coverage}', { coverage: focus.coverage || t('unknown') }),
+ t('Coverage {coverage}', { coverage: coverageText(focus.coverage) }),
);
lines.push({ id: 'acquired', text: times.join(' · ') });
(focus.granules || []).forEach((granule, index) => {
diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs
index b91169739..af1758166 100644
--- a/src/ui/recentImagery.test.mjs
+++ b/src/ui/recentImagery.test.mjs
@@ -646,7 +646,10 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
f.layer.focus(1);
const s18 = f.card(S18);
assert.equal(f.part(s18, 'ri-card-date').textContent, 'Sep 18');
- assert.equal(f.part(s18, 'ri-card-sensor').textContent, 'Sentinel-2 · 30 m');
+ assert.equal(
+ f.part(s18, 'ri-card-sensor').textContent,
+ 'Sentinel-2 · 30\u00a0m',
+ );
assert.equal(f.part(s18, 'ri-card-cloud').textContent, '12% cloud');
assert.equal(f.part(s18, 'ri-card-start').hidden, false);
assert.match(f.part(s18, 'ri-card-start').title, /Newest low-cloud day/);
@@ -658,11 +661,11 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
assert.equal(f.part(f.card(V21), 'ri-thumb-text').textContent, 'Checking');
assert.equal(
f.part(f.card(V21), 'ri-card-sensor').textContent,
- 'Daily overview · 250 m',
+ 'Daily overview · 250\u00a0m',
);
assert.equal(
f.part(f.card(L16), 'ri-card-sensor').textContent,
- 'Landsat 8/9 · 30 m',
+ 'Landsat 8/9 · 30\u00a0m',
);
// IMAGE: one SHOW chip; the B chip keeps its box but is invisible.
const [a, b] = [f.chip(S18, 'a'), f.chip(S18, 'b')];
@@ -734,7 +737,10 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
['A / B', 'radio', 'false'],
],
);
- assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m · preview');
+ assert.equal(
+ rowText(f, 'a'),
+ 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m · preview',
+ );
assert.equal(f.byId('ri-slot-a').dataset.state, 'preview');
assert.equal(rowText(f, 'b'), 'VS | Basemap');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), false, 'dimmed');
@@ -748,7 +754,7 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
[true, true, '×'],
);
f.key('s');
- assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30 m');
+ assert.equal(rowText(f, 'a'), 'IMAGE | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(f.byId('ri-slot-a').dataset.state, 'pinned');
assert.equal(unpinA.disabled, false);
assert.equal(unpinA.getAttribute('aria-label'), 'Unpin the image');
@@ -757,12 +763,12 @@ test('the selection bar reads the mode, both rows and a preview, with × reserve
assert.equal(modes[1].getAttribute('aria-checked'), 'true');
assert.equal(f.byId('ri-slot-b').classList.contains('lit'), true, 'lit');
modes[2].click();
- assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30 m');
+ assert.equal(rowText(f, 'a'), 'A | Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(rowText(f, 'b'), 'B | Not set');
assert.equal(f.byId('ri-slot-b').dataset.state, 'empty');
assert.equal(f.byId('ri-unpin-b').classList.contains('is-reserved'), true);
f.chip(L16, 'b').click();
- assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30 m');
+ assert.equal(rowText(f, 'b'), 'B | Sep 16 · Landsat 8/9 · 30\u00a0m');
f.byId('ri-unpin-b').click();
assert.equal(f.snap().pins.b.key, null);
unpinA.click();
@@ -879,7 +885,10 @@ test('the notice line shows the refusal, then errors, then CLEAR, then the Esri
);
assert.equal(notice.classList.contains('info'), true);
f.layer.setBox({ west: 0, south: 0, east: 20, north: 20 });
- assert.equal(noticeText.textContent, 'Box is 2,226 km wide · limit 1,000 km');
+ assert.equal(
+ noticeText.textContent,
+ 'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
+ );
assert.equal(notice.classList.contains('warn'), true);
assert.equal(hint.textContent, 'Zoom in or draw a smaller box');
f.layer.setBox(BOX);
@@ -929,7 +938,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
assert.deepEqual(reserved(), [false, false, 'false', true]);
assert.equal(
f.byId('ri-notice-text').textContent,
- 'Box is 2,226 km wide · limit 1,000 km',
+ 'Box is 2,226\u00a0km wide · limit 1,000\u00a0km',
);
assert.equal(f.byId('ri-hint').textContent, 'Zoom in or draw a smaller box');
assert.equal(zoom.title, 'Fly in until the view fits the 1,000 km limit');
@@ -998,7 +1007,10 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
[split.options.beforeLabel, split.options.afterLabel],
['A', 'B'],
);
- assert.equal(split.options.afterTitle, 'Landsat 8/9 · 30 m · 2026-09-16');
+ assert.equal(
+ split.options.afterTitle,
+ 'Landsat 8/9 · 30\u00a0m · 2026-09-16',
+ );
split.options.onChange(0.3);
assert.equal(f.snap().split, 0.3);
assert.deepEqual(directions(), ['left', 'right']);
@@ -1015,7 +1027,7 @@ test('the divider exists only while a swipe is live, labelled A / B or IMAGE / B
assert.deepEqual(labels(), ['B', 'A']);
assert.equal(
f.splits.at(-1).options.beforeTitle,
- 'Landsat 8/9 · 30 m · 2026-09-16',
+ 'Landsat 8/9 · 30\u00a0m · 2026-09-16',
);
assert.equal(f.splits.at(-1).options.initialValue, 0.3);
assert.deepEqual(directions(), ['right', 'left']);
diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js
index 846f55274..69777708d 100644
--- a/src/ui/sceneControls.js
+++ b/src/ui/sceneControls.js
@@ -163,7 +163,9 @@ export class SceneControls {
const shot = scene?.shots.find((item) => item.id === shotId);
if (
shot &&
- window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
+ window.confirm(
+ t('Delete shot "{shot}"?', { shot: shot.displayTitle || shot.title }),
+ )
)
this.run('deleteShot', sceneId, shotId);
}
diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js
index dfe78ce9c..727975a6b 100644
--- a/src/ui/scenePresentation.js
+++ b/src/ui/scenePresentation.js
@@ -1,5 +1,6 @@
/** Scene panel elements and text-only project presentation. */
import { msg, t } from '../i18n/index.js';
+import { formatQuantity } from './unitLabels.js';
// Labels for what a shot stores; anything else shows as stored. Style names
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
@@ -76,6 +77,9 @@ export function renderSceneShots(
return;
}
for (const shot of scene.shots) {
+ // Built-in titles arrive localized as displayTitle; the stored title
+ // stays English, so a rename happens only when the shown text changes.
+ const shownTitle = shot.displayTitle || shot.title;
const row = document.createElement('div');
row.className = 'scene-shot-row';
row.dataset.sceneShotId = shot.id;
@@ -84,7 +88,7 @@ export function renderSceneShots(
top.className = 'scene-shot-top';
const label = document.createElement('div');
label.className = 'scene-shot-label';
- label.textContent = shot.title;
+ label.textContent = shownTitle;
label.title = t('Double-click to rename');
listen(label, 'click', () => select(shot.id));
listen(label, 'dblclick', () => {
@@ -93,14 +97,14 @@ export function renderSceneShots(
input.type = 'text';
input.className = 'scene-shot-rename';
input.setAttribute('aria-label', t('Shot name'));
- input.value = shot.title;
+ input.value = shownTitle;
let finished = false;
const finish = (save) => {
if (finished) return;
finished = true;
const title = input.value.trim();
- label.textContent = shot.title;
- if (save && title && title !== shot.title)
+ label.textContent = shownTitle;
+ if (save && title && title !== shownTitle)
rename(scene.id, shot.id, title);
};
listen(input, 'keydown', (event) => {
@@ -140,7 +144,12 @@ export function renderSceneShots(
const modeLabel = DETECTION_MODE_LABELS[mode]
? t(DETECTION_MODE_LABELS[mode])
: mode;
- meta.textContent = `${styleLabel} · ${modeLabel} · ${shot.durationSec.toFixed(1)}s + ${shot.holdSec.toFixed(1)}s`;
+ const seconds = (value) =>
+ formatQuantity(value, 's', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+ meta.textContent = `${styleLabel} · ${modeLabel} · ${seconds(shot.durationSec)} + ${seconds(shot.holdSec)}`;
row.appendChild(top);
row.appendChild(meta);
element.appendChild(row);
diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js
index dc0a96d86..f8ce8e51a 100644
--- a/src/ui/sceneSharing.js
+++ b/src/ui/sceneSharing.js
@@ -1,4 +1,4 @@
-import { t } from '../i18n/index.js';
+import { t, tn } from '../i18n/index.js';
/** Small owned modal for scene authoring and import review; text never becomes markup. */
export function createSceneDialog(title, onClose) {
@@ -81,10 +81,67 @@ export function createSceneDialog(title, onClose) {
});
if (multiline) node.rows = 9;
wrapper.append(node);
+ if (type === 'file') fileChooser(wrapper, node);
body.append(wrapper);
return node;
}
- button('Cancel', 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.
+ */
+ function fileChooser(wrapper, node) {
+ wrapper.style.position = 'relative';
+ Object.assign(node.style, {
+ position: 'absolute',
+ width: '1px',
+ height: '1px',
+ margin: '0',
+ padding: '0',
+ overflow: 'hidden',
+ clip: 'rect(0 0 0 0)',
+ whiteSpace: 'nowrap',
+ border: '0',
+ });
+ const row = document.createElement('span');
+ row.setAttribute('aria-hidden', 'true');
+ Object.assign(row.style, {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ margin: '6px 0 14px',
+ padding: '7px',
+ background: '#10242e',
+ border: '1px solid #397080',
+ });
+ const chooser = document.createElement('span');
+ chooser.textContent = t('Choose files');
+ Object.assign(chooser.style, {
+ padding: '3px 9px',
+ border: '1px solid #397080',
+ borderRadius: '3px',
+ background: '#08141e',
+ cursor: 'pointer',
+ });
+ const chosen = document.createElement('span');
+ const showChosen = () => {
+ const count = node.files?.length || 0;
+ chosen.textContent = count
+ ? tn(count, '{count} file selected', '{count} files selected')
+ : t('No file chosen');
+ };
+ showChosen();
+ row.append(chooser, chosen);
+ wrapper.append(row);
+ listen(node, 'change', showChosen);
+ listen(node, 'focus', () => {
+ chooser.style.outline = '2px solid #e3faff';
+ });
+ listen(node, 'blur', () => {
+ chooser.style.outline = '';
+ });
+ }
+ button(t('Cancel'), onClose);
listen(dialog, 'cancel', (event) => {
event.preventDefault();
onClose();
diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js
index 9352cfe7a..ec2d00ef0 100644
--- a/src/ui/shellFeedback.js
+++ b/src/ui/shellFeedback.js
@@ -10,6 +10,7 @@ import {
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { t, msg } from '../i18n/index.js';
+import { localizeLayerName } 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
@@ -46,7 +47,21 @@ function localizeStatusDetail(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');
- return detail;
+ return localizeLayerRoster(detail);
+}
+
+// The loading detail is the active layers' English names joined by " · ",
+// with " +N" when more are loading. Translate each known layer name; anything
+// else (a provider such as "OpenStreetMap · Overpass") passes through.
+function localizeLayerRoster(detail) {
+ const [, names, suffix = ''] = detail.match(/^(.*?)( \+\d+)?$/s) || [];
+ if (!names) return detail;
+ return (
+ names
+ .split(' · ')
+ .map((name) => localizeLayerName(name))
+ .join(' · ') + suffix
+ );
}
function localizeLoadingPresentation(presentation) {
diff --git a/src/ui/styles/bhote-koshi.css b/src/ui/styles/bhote-koshi.css
index 952e4d07b..b2d774ce9 100644
--- a/src/ui/styles/bhote-koshi.css
+++ b/src/ui/styles/bhote-koshi.css
@@ -12,7 +12,9 @@
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
- box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
+ box-shadow:
+ 0 18px 52px rgba(0, 0, 0, 0.48),
+ inset 0 0 28px rgba(14, 78, 92, 0.12);
backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
letter-spacing: 0;
@@ -153,33 +155,69 @@
}
@keyframes bhote-embedded-anchor-enter {
- from { opacity: 0; transform: scale(0.2); }
- to { opacity: 1; transform: scale(1); }
+ from {
+ opacity: 0;
+ transform: scale(0.2);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
}
@keyframes bhote-embedded-leader-enter {
- from { opacity: 0; stroke-dashoffset: 1; }
- to { opacity: 1; stroke-dashoffset: 0; }
+ from {
+ opacity: 0;
+ stroke-dashoffset: 1;
+ }
+ to {
+ opacity: 1;
+ stroke-dashoffset: 0;
+ }
}
@keyframes bhote-embedded-body-enter {
- from { opacity: 0; transform: translateY(8px) scale(0.98); }
- to { opacity: 1; transform: translateY(0) scale(1); }
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
@keyframes bhote-embedded-body-exit {
- from { opacity: 1; transform: translateY(0) scale(1); }
- to { opacity: 0; transform: translateY(6px) scale(0.985); }
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(6px) scale(0.985);
+ }
}
@keyframes bhote-embedded-leader-exit {
- from { opacity: 1; stroke-dashoffset: 0; }
- to { opacity: 0; stroke-dashoffset: 1; }
+ from {
+ opacity: 1;
+ stroke-dashoffset: 0;
+ }
+ to {
+ opacity: 0;
+ stroke-dashoffset: 1;
+ }
}
@keyframes bhote-embedded-anchor-exit {
- from { opacity: 1; transform: scale(1); }
- to { opacity: 0; transform: scale(0.2); }
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.2);
+ }
}
@media (prefers-reduced-motion: reduce) {
@@ -243,16 +281,20 @@
border-top: 3px solid #6be8ff;
}
+/* Up to two lines, so a translated title is not cut to a stub. */
.bhote-embedded-callout footer strong {
+ display: -webkit-box;
overflow: hidden;
font-size: 11px;
line-height: 1.25;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
}
+/* The status takes the full width under the title and link. */
.bhote-embedded-callout footer small {
- grid-column: 1;
+ grid-column: 1 / -1;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
@@ -260,8 +302,9 @@
.bhote-embedded-callout footer a {
grid-column: 2;
- grid-row: 1 / span 2;
+ grid-row: 1;
align-self: center;
+ white-space: nowrap;
color: #6be8ff;
font-size: 8px;
text-decoration: none;
@@ -372,6 +415,11 @@
color: rgba(174, 208, 216, 0.62);
}
+/* Keep "16 OCT 2021" together so the year never wraps alone. */
+.bhote-event-date {
+ white-space: nowrap;
+}
+
.bhote-event-story-nav {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
@@ -388,7 +436,11 @@
border-radius: 4px;
color: var(--accent);
background: rgba(7, 25, 35, 0.9);
- font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
+ font:
+ 22px/1 'JetBrains Mono',
+ 'SFMono-Regular',
+ Consolas,
+ monospace;
cursor: pointer;
}
@@ -419,13 +471,15 @@
white-space: nowrap;
}
+/* Phase / time / sources run long in translation: allow a second line. */
.bhote-event-story-current small {
+ display: -webkit-box;
grid-column: 1 / -1;
margin-top: 4px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
- text-overflow: ellipsis;
- white-space: nowrap;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
}
.bhote-event-actions {
@@ -448,14 +502,15 @@
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: 0 5px;
+ padding: 3px 5px;
overflow: hidden;
font-size: 8px;
- text-overflow: ellipsis;
- white-space: nowrap;
+ line-height: 1.2;
+ overflow-wrap: break-word;
}
.bhote-event-actions button:disabled,
@@ -469,7 +524,7 @@
border-color: rgba(88, 196, 218, 0.36);
}
-.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
+.bhote-event-actions button[data-action='cinematic'][aria-pressed='true'] {
color: #071017;
border-color: var(--accent);
background: var(--accent);
@@ -593,7 +648,6 @@
max-height: 48vh;
overflow-y: auto;
}
-
}
* {
@@ -602,7 +656,8 @@
box-sizing: border-box;
}
-html, body {
+html,
+body {
width: 100%;
height: 100%;
overflow: hidden;
diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css
index 38261d501..74a1de034 100644
--- a/src/ui/styles/cctv.css
+++ b/src/ui/styles/cctv.css
@@ -78,6 +78,14 @@ html[lang='es'] .cctv-controls {
flex-wrap: wrap;
}
+/* A translated label longer than its button wraps inside the button instead
+ of running past the panel edge. English labels fit on one line, so the
+ English layout is unchanged. */
+.cctv-controls .scene-btn {
+ white-space: normal;
+ overflow-wrap: break-word;
+}
+
.cctv-calibration-block {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
@@ -163,8 +171,12 @@ html[lang='es'] .cctv-controls {
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
- font-family: var(--font-sans);
- font-size: 12px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
outline: none;
}
diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css
index 8436c3a41..f6678f761 100644
--- a/src/ui/styles/cockpit.css
+++ b/src/ui/styles/cockpit.css
@@ -1499,6 +1499,12 @@ body.cockpit-mode #view-switcher button {
.cockpit-signal-header > div {
display: grid;
gap: 2px;
+ min-width: 0;
+}
+.cockpit-signal-kicker {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.cockpit-signal-header strong {
color: #fff;
@@ -1526,6 +1532,11 @@ body.cockpit-mode #view-switcher button {
align-items: center;
gap: 3px;
}
+/* A longer translated state label widens the cycle button; the other
+ controls keep their size instead of being squeezed. */
+.cockpit-brief-actions > * {
+ flex-shrink: 0;
+}
.cockpit-brief-actions button {
display: grid;
width: 23px;
@@ -1548,10 +1559,13 @@ body.cockpit-mode #view-switcher button {
font-size: 14px;
}
#cockpit-brief-auto {
- width: 50px;
+ width: auto;
+ min-width: 50px;
+ padding: 0 4px;
color: rgba(244, 251, 255, 0.42);
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
+ white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
border-color: var(--cockpit-accent);
@@ -1857,14 +1871,20 @@ 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 {
+ white-space: nowrap;
+}
.cockpit-brief-tabs button {
padding: 2px 4px;
border: 0;
diff --git a/src/ui/styles/command-dock-sliding.css b/src/ui/styles/command-dock-sliding.css
index 396a34c21..556603d1c 100644
--- a/src/ui/styles/command-dock-sliding.css
+++ b/src/ui/styles/command-dock-sliding.css
@@ -696,3 +696,54 @@
rgba(232, 234, 237, 0.24)
);
}
+
+/* Language switch under the Visual Presets title: small mono chips, the
+ active language in accent like the other dock toggles. */
+#command-dock #control-panel .dock-locale-switch {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.2rem;
+ margin-top: 0.22rem;
+}
+
+#command-dock .dock-locale-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 1.35rem;
+ height: 0.95rem;
+ margin: 0;
+ padding: 0 0.22rem;
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 0.18rem;
+ font-family: var(--font-mono);
+ font-size: 0.46rem;
+ font-weight: 500;
+ letter-spacing: 0.06rem;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ color var(--transition-fast),
+ border-color var(--transition-fast),
+ background var(--transition-fast);
+}
+
+#command-dock .dock-locale-btn:hover {
+ color: var(--text-primary);
+ border-color: var(--glass-border-hover);
+ background: rgba(255, 255, 255, 0.06);
+}
+
+#command-dock .dock-locale-btn:focus-visible {
+ outline: 1px solid var(--text-primary);
+ outline-offset: 1px;
+}
+
+#command-dock .dock-locale-btn.active {
+ color: var(--accent);
+ border-color: rgba(0, 212, 255, 0.55);
+ background: var(--accent-dim);
+}
diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css
index 4631bfcff..eef3c6001 100644
--- a/src/ui/styles/command-dock-trays.css
+++ b/src/ui/styles/command-dock-trays.css
@@ -392,6 +392,12 @@
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
+/* The side wings sit at z-index 100 (controls.css, location.css), so their
+ open trays would cover the alert; lift the voice column while it shows. */
+#command-dock > #gev-voice-control[data-status='error']:not(.error-dismissed) {
+ z-index: 101;
+}
+
#command-dock
#gev-voice-control[data-status='error']:not(.error-dismissed)
.gev-voice-error-tray {
diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css
index 8607cd459..19e189747 100644
--- a/src/ui/styles/controls.css
+++ b/src/ui/styles/controls.css
@@ -228,6 +228,8 @@
}
.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;
@@ -770,6 +772,15 @@
white-space: nowrap;
}
+/* 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 {
+ flex: 0 0 auto;
+ min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
+}
+
.detection-slider-row {
min-width: 170px;
padding: 5px 8px 6px;
@@ -987,8 +998,11 @@
.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;
+ flex: 0 0 auto;
+ min-width: 74px;
}
.draw-clear-btn {
flex: 0 0 auto;
@@ -1234,15 +1248,20 @@ body.ui-clean-view #scene-runtime.active {
);
}
-.param-slider-row {
- display: flex;
+/* 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 {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
- margin-bottom: 10px;
}
-.param-slider-row:last-child {
- margin-bottom: 0;
+.param-slider-row {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+ align-items: center;
}
.param-label {
diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css
index 9b2534054..bfd97f895 100644
--- a/src/ui/styles/layers.css
+++ b/src/ui/styles/layers.css
@@ -1371,10 +1371,12 @@
padding: 2px 4px;
}
-/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
+/* ALPR keeps its nearby count in the legend while a wider status chip
+ (including ENABLING / DISABLING) is shown. */
.data-toggle-row[data-layer-id='alpr-cameras']
.data-toggle-right:has(
.data-toggle-btn:is(
+ .transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css
index 1fa4f29e2..f6ab14f37 100644
--- a/src/ui/styles/radio.css
+++ b/src/ui/styles/radio.css
@@ -250,8 +250,12 @@
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;
@@ -610,10 +614,14 @@
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 {
@@ -621,16 +629,27 @@
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;
}
.data-toggle-right {
display: flex;
+ flex: 0 1 auto;
align-items: center;
gap: 10px;
}
@@ -640,8 +659,8 @@
font-size: 11px;
letter-spacing: 0.5px;
color: var(--text-secondary);
- min-width: 30px;
text-align: right;
+ text-wrap: balance;
}
.data-toggle-btn {
@@ -662,8 +681,10 @@
min-width var(--transition-fast),
letter-spacing var(--transition-fast);
outline: none;
+ flex: none;
min-width: 38px;
text-align: center;
+ white-space: nowrap;
}
/* An inset ring stays visible inside the scrolling layer list. */
@@ -883,14 +904,16 @@
.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;
+ min-width: 0;
+ overflow-wrap: anywhere;
}
/* A list line that states a limit rather than offering an action. */
diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css
index f1e2e20e5..56cef6c17 100644
--- a/src/ui/styles/recent-imagery.css
+++ b/src/ui/styles/recent-imagery.css
@@ -293,17 +293,34 @@
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'])
+ .ri-card-chips
+ .ri-chip[data-slot='a'] {
+ grid-column: 1 / -1;
+}
+.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. */
+/* 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. */
.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;
@@ -312,14 +329,16 @@
height: 20px;
}
.recent-imagery-readout .ri-slot {
+ grid-column: 1 / -1;
display: grid;
- grid-template-columns: 44px minmax(0, 1fr) 22px;
+ grid-template-columns: subgrid;
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;
@@ -355,19 +374,25 @@
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
- while no swipe is live. */
+ 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. */
.recent-imagery-readout .ri-controls {
display: grid;
- grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
+ grid-template-columns:
+ minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
+ 128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
+ flex-wrap: wrap;
justify-content: space-between;
- gap: 4px;
+ align-content: center;
+ column-gap: 4px;
font-size: 8px;
+ line-height: 10px;
letter-spacing: 1px;
white-space: nowrap;
}
@@ -385,7 +410,7 @@
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
- width: 40px;
+ min-width: 40px;
padding: 0 2px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css
index c9e92bea2..8e9a5f3f6 100644
--- a/src/ui/styles/scenes.css
+++ b/src/ui/styles/scenes.css
@@ -158,11 +158,23 @@
border-radius: 7px;
color: var(--text-primary);
padding: 6px 8px;
- font-family: var(--font-sans);
- font-size: 12px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
outline: none;
}
+/* 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 {
+ white-space: normal;
+ overflow-wrap: break-word;
+}
+
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css
index 5cb1a4122..b8610d010 100644
--- a/src/ui/styles/voice-cost.css
+++ b/src/ui/styles/voice-cost.css
@@ -82,3 +82,61 @@
.gev-voice-cost-value[hidden] {
display: none;
}
+
+/* The dock heading row is only as wide as the voice column, so the cost
+ cluster keeps its size and the state label gives way: a long translated
+ state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an ellipsis instead of pushing the
+ cost past the card edge. The kicker already collapses to zero width here;
+ taking it out of the row returns its gap to the state label. The row also
+ reaches left over the gutter beside the mic ring: at heading height the
+ ring has already curved away. */
+#command-dock .gev-voice-heading {
+ gap: 0.2rem;
+ margin-left: -0.6rem;
+}
+
+#command-dock .gev-voice-heading > .gev-voice-kicker {
+ display: none;
+}
+
+#command-dock .gev-voice-heading > #gev-voice-status {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ letter-spacing: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#command-dock .gev-voice-cost {
+ gap: 0.16rem;
+ padding-left: 0.1rem;
+}
+
+#command-dock .gev-voice-tier-btn {
+ min-width: 0;
+ padding: 0 0.2rem;
+}
+
+#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. */
+#command-dock
+ #gev-voice-control:is(
+ [data-status='connecting'],
+ [data-status='listening'],
+ [data-status='executing']
+ )
+ #gev-voice-detail {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ line-height: 1.15;
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css
index 3e83561fb..8a431df14 100644
--- a/src/ui/styles/weather.css
+++ b/src/ui/styles/weather.css
@@ -258,6 +258,7 @@ 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);
}
@@ -311,9 +312,18 @@ body.recording-mode .gev-wind-inspection-marker {
.rail-card-compact.status {
color: var(--accent);
}
+/* 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 {
+ display: grid;
+ grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
+ column-gap: 6px;
+}
.rail-card-setting {
display: grid;
- grid-template-columns: 56px minmax(0, 1fr);
+ 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;
diff --git a/src/ui/unitLabels.js b/src/ui/unitLabels.js
new file mode 100644
index 000000000..1d9bd6a61
--- /dev/null
+++ b/src/ui/unitLabels.js
@@ -0,0 +1,49 @@
+import { formatNumber, msg, t } from '../i18n/index.js';
+
+// Unit ids stay English in data, params and AI-tool readings; only the label
+// shown on the page is translated. dBZ and °C read the same in every language.
+const UNIT_LABELS = Object.freeze({
+ 'km/h': msg('km/h'),
+ 'm/s': msg('m/s'),
+ mph: msg('mph'),
+ hPa: msg('hPa'),
+ kt: msg('kt'),
+ kts: msg('kts'),
+ ft: msg('ft'),
+ km: msg('km'),
+ m: msg('m'),
+ h: msg('h'),
+ min: msg('min'),
+ s: msg('s'),
+ dB: msg('dB'),
+});
+
+/** The display label for a unit id; unknown units pass through unchanged. */
+export function unitLabel(unit) {
+ return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
+}
+
+/**
+ * A number and its unit that never break apart: "22,0 км/ч". Readings carry
+ * no digit grouping ("1008 hPa"), as on weather charts.
+ */
+export function formatQuantity(value, unit, options) {
+ const label = unitLabel(unit);
+ const number = formatNumber(value, { useGrouping: false, ...options });
+ return label ? `${number}\u00a0${label}` : number;
+}
+
+/**
+ * A legend stop such as "0.1", "-40" or "300+" in the active language.
+ * Anything that is not a plain number is shown as given.
+ */
+export function formatScaleLabel(label) {
+ const match = /^(-?\d+(?:\.\d+)?)(\+?)$/.exec(String(label ?? ''));
+ if (!match) return String(label ?? '');
+ const decimals = match[1].split('.')[1]?.length || 0;
+ return `${formatNumber(Number(match[1]), {
+ useGrouping: false,
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ })}${match[2]}`;
+}
diff --git a/src/ui/unitLabels.test.mjs b/src/ui/unitLabels.test.mjs
new file mode 100644
index 000000000..c0b1932b1
--- /dev/null
+++ b/src/ui/unitLabels.test.mjs
@@ -0,0 +1,46 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { setLocale } from '../i18n/index.js';
+import { formatQuantity, formatScaleLabel, unitLabel } from './unitLabels.js';
+
+test('English readings and scales read exactly as the unit ids', () => {
+ setLocale('en');
+ assert.equal(unitLabel('km/h'), 'km/h');
+ assert.equal(unitLabel('°C'), '°C');
+ assert.equal(unitLabel('dBZ'), 'dBZ');
+ assert.equal(unitLabel(''), '');
+ assert.equal(formatQuantity(95, 'kt'), '95 kt');
+ assert.equal(formatQuantity(1006, 'hPa'), '1006 hPa');
+ assert.equal(
+ formatQuantity(22, 'km/h', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ }),
+ '22.0 km/h',
+ );
+ assert.deepEqual(
+ ['0.1', '-40', '1050+', '300+', 'Calm'].map(formatScaleLabel),
+ ['0.1', '-40', '1050+', '300+', 'Calm'],
+ );
+});
+
+test('other languages get their decimal separator and no digit grouping', () => {
+ try {
+ setLocale('ru');
+ assert.match(
+ formatQuantity(22, 'km/h', {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ }),
+ /^22,0 \S+$/,
+ );
+ assert.match(formatQuantity(1050, 'hPa'), /^1050 /);
+ assert.deepEqual(['0.1', '-40', '1050+'].map(formatScaleLabel), [
+ '0,1',
+ '-40',
+ '1050+',
+ ]);
+ } finally {
+ setLocale('en');
+ }
+});
diff --git a/src/voice/control.js b/src/voice/control.js
index 027f738ce..f44737747 100644
--- a/src/voice/control.js
+++ b/src/voice/control.js
@@ -4,6 +4,31 @@ import { t } from '../i18n/index.js';
// The assistant's name on its card. i18n-ignore: product name
const ASSISTANT_NAME = 'GEV';
+/**
+ * The short state label in the voice heading. The heading is a few characters
+ * wide next to the cost readout, so these carry their own `voice` context and
+ * languages can pick shorter words than the shared CONNECTING / OFF labels.
+ * Unknown states show as they are, upper-cased.
+ * @param {string} status machine state (dataset.status)
+ * @returns {string}
+ */
+export function voiceStatusLabel(status) {
+ switch (status) {
+ case 'idle':
+ return t('OFF', { context: 'voice' });
+ case 'connecting':
+ return t('CONNECTING', { context: 'voice' });
+ case 'listening':
+ return t('LISTENING', { context: 'voice' });
+ case 'executing':
+ return t('EXECUTING', { context: 'voice' });
+ case 'error':
+ return t('ERROR', { context: 'voice' });
+ default:
+ return String(status || '').toUpperCase();
+ }
+}
+
export function createVoiceControl({ reset = false } = {}) {
let root = document.getElementById('gev-voice-control');
if (root && reset) {
@@ -18,7 +43,7 @@ export function createVoiceControl({ reset = false } = {}) {
root.innerHTML = `
${t('AI AGENT')}
-
${t('OFF')}
+
${voiceStatusLabel('idle')}
~$0.00
diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js
index 7c0cccc6c..313eaabb7 100644
--- a/src/voice/realtimeController.js
+++ b/src/voice/realtimeController.js
@@ -55,17 +55,18 @@ export {
import { createRealtimeBackend } from './realtimeBackend.js';
-import { t, msg } from '../i18n/index.js';
+import { t } from '../i18n/index.js';
+import { voiceStatusLabel } from './control.js';
// Status enum VALUES are machine identifiers (dataset.status, CSS hooks);
// only the LABEL rendered from them localizes, resolved at write time via t().
-const STATUS_LABEL_KEYS = Object.freeze({
- idle: msg('OFF'),
- connecting: msg('CONNECTING'),
- listening: msg('LISTENING'),
- executing: msg('EXECUTING'),
- error: msg('ERROR'),
-});
+const STATUS_STATES = new Set([
+ 'idle',
+ 'connecting',
+ 'listening',
+ 'executing',
+ 'error',
+]);
/** Compose voice state owners and coordinate ordered session startup/teardown. */
export class GevRealtimeController extends RealtimeFacade {
@@ -436,9 +437,8 @@ export class GevRealtimeController extends RealtimeFacade {
this.ui.root.dataset.status = status;
if (status === 'error') this.ui.root.classList.remove('error-dismissed');
this.updateVoiceButtonLabel();
- this.ui.status.textContent = t(
- STATUS_LABEL_KEYS[status] || STATUS_LABEL_KEYS.idle,
- );
+ const shownStatus = STATUS_STATES.has(status) ? status : 'idle';
+ this.ui.status.textContent = voiceStatusLabel(shownStatus);
const resolvedDetail =
status === 'listening' && this.pushToTalkMode
? this.pushToTalkKeyHeld
diff --git a/src/voice/realtimeInput.js b/src/voice/realtimeInput.js
index adc9281f3..d82b9465d 100644
--- a/src/voice/realtimeInput.js
+++ b/src/voice/realtimeInput.js
@@ -364,8 +364,8 @@ export class RealtimeInput {
*/
updateVoiceButtonLabel() {
if (!this.ui.buttonLabel) return;
- // i18n-ignore: the GEV MIC button's name; translated tips refer to it as MIC
- this.ui.buttonLabel.textContent = 'MIC';
+ // The mic button's caption; the first-run tip names it the same way.
+ this.ui.buttonLabel.textContent = t('MIC');
if (this.ui.helpDetail) {
this.ui.helpDetail.textContent = resolveVoiceControlHint(
this.pushToTalkMode,
diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js
index 4ac70fe4e..fa7b72def 100644
--- a/src/voice/sessionCommands.js
+++ b/src/voice/sessionCommands.js
@@ -1,18 +1,8 @@
-import { createVoiceControl } from './control.js';
+import { createVoiceControl, voiceStatusLabel } from './control.js';
import { createVoiceSession } from './session.js';
import { VoiceCardControls } from './voiceCard.js';
import { PointerReticle } from './pointerReticle.js';
-import { t, msg } from '../i18n/index.js';
-
-// Session states are machine values (dataset.status); only the label shown for
-// them is translated. Unknown states show as they are, upper-cased.
-const SESSION_STATE_LABELS = Object.freeze({
- idle: msg('OFF'),
- connecting: msg('CONNECTING'),
- listening: msg('LISTENING'),
- executing: msg('EXECUTING'),
- error: msg('ERROR'),
-});
+import { t } from '../i18n/index.js';
function createDefaultReticle(pointer) {
const element = pointer?.element;
@@ -73,9 +63,9 @@ export function createVoiceCommands({
const updateStatus = session.subscribe((event) => {
if (event.type !== 'state') return;
ui.root.dataset.status = event.state;
- ui.status.textContent = SESSION_STATE_LABELS[event.state]
- ? t(SESSION_STATE_LABELS[event.state])
- : event.state.toUpperCase();
+ // Session states are machine values (dataset.status); only the label
+ // shown for them is translated. Unknown states show as they are.
+ ui.status.textContent = voiceStatusLabel(event.state);
// The session reports 'Voice off' in English: it is portable and has no
// locale, so that detail shows as the idle label. Other details are
// provider messages and show as they arrive.
diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js
index 2a902effb..0f0146ce1 100644
--- a/src/voice/voiceCardPresentation.js
+++ b/src/voice/voiceCardPresentation.js
@@ -2,7 +2,7 @@ import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
import { presentResult } from './resultDisplay.js';
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
-import { t } from '../i18n/index.js';
+import { msg, t } from '../i18n/index.js';
/**
* Pure presentation of the voice card nested in the voice control: captions,
* the current plan, the latest result and its numbered referents.
@@ -19,10 +19,10 @@ const MAX_NOTES = 6;
const MAX_CAPTION = 220;
const STATUS_TEXT = Object.freeze({
- running: 'in progress',
- done: 'done',
- failed: 'failed',
- cancelled: 'cancelled',
+ running: msg('in progress'),
+ done: msg('done'),
+ failed: msg('failed'),
+ cancelled: msg('cancelled'),
});
/** An empty, hidden card. */
@@ -191,7 +191,7 @@ export function reduceVoiceCard(state, event) {
: result.ok
? 'done'
: 'failed',
- detail: result.outlinePending ? 'Tracing outline' : '',
+ detail: result.outlinePending ? t('Tracing outline') : '',
annotationIds: result.outlinePending ? annotationIds(result) : [],
}),
);
@@ -225,10 +225,10 @@ export function reduceVoiceCard(state, event) {
...step,
annotationIds: remaining,
detail: remaining.length
- ? 'Tracing outline'
+ ? t('Tracing outline')
: event.status === 'resolved'
- ? 'Outline traced'
- : 'Outline unavailable',
+ ? t('Outline traced')
+ : t('Outline unavailable'),
};
return reveal({ ...state, steps });
}
@@ -270,7 +270,7 @@ export function voiceCardView(state) {
return {
visible: Boolean(state.visible),
busy: running,
- phase: running ? 'WORKING' : state.steps.length ? 'DONE' : '',
+ phase: running ? t('WORKING') : state.steps.length ? t('DONE') : '',
user: {
visible: state.user !== null,
text: state.user || '…',
@@ -288,7 +288,7 @@ export function voiceCardView(state) {
key: String(step.id),
label: step.label,
status: step.status,
- statusText: STATUS_TEXT[step.status] || '',
+ statusText: STATUS_TEXT[step.status] ? t(STATUS_TEXT[step.status]) : '',
detail: step.detail || '',
})),
result: {