fix(i18n): fix Russian layouts and the gaps the screenshot review found

A review of the Russian UI screenshots found ~90 places where longer words
broke the layout or text was still English. Fixed so they hold for any
language, then shortened where a control is short by design.

- Layout: label columns that size to their text (weather and wind settings,
  Recent Imagery slots and opacity, style sliders, display panel), layer
  list names and icons, buttons that wrap instead of overflowing (CCTV,
  scenes, Bhote Koshi), voice header that always shows the cost, styled
  EN | ES | RU chips, voice error tray above the dock.
- t() context: t('CLEAR', { context: 'action' }) is «СБРОСИТЬ» while the sky
  stays «ЯСНО»; also used for radio categories, voice states and directions.
- Units and dates follow the locale through src/ui/unitLabels.js
  (formatQuantity: localized number, translated unit, non-breaking space):
  wind, pressure, cyclones, directions, CCTV, SDR gain, scene timing, flight
  tooltip, Recent Imagery dates and resolution.
- Newly translated: directions turn-by-turn steps (built from the maneuver;
  English matches the server word for word), HUD mode and summary, voice
  card states, radio categories on the globe, CCTV status, layer source
  lines, scene dialogs (Cancel, file pickers), Bhote Koshi statuses, titles,
  comparison dates and caveat, Recent Imagery notices. Stored data and text
  for AI tools stay English.
- es and ru: 1,885 messages each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 21:18:03 +04:00
1 parent 3129e531e6
commit 2b6ccad732
71 files changed
+2435 -370

No files matched your search

+1 -1
View File
@@ -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
+7 -5
View File
@@ -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]) });
}
}
}
+25 -11
View File
@@ -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"]
},
+26 -5
View File
@@ -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. */
+58 -3
View File
@@ -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';
+82 -17
View File
@@ -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)
+98 -4
View File
@@ -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м');
});
+109 -11
View File
@@ -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 {
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
@@ -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;
+25 -3
View File
@@ -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 <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`.
*/
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 });
}
+51 -9
View File
@@ -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',
);
});
+6
View File
@@ -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');
});
+5 -2
View File
@@ -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);
}
+228 -18
View File
@@ -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}"
}
+247 -36
View File
@@ -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}"
}
+7 -3
View File
@@ -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 },
})),
+2 -1
View File
@@ -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);
+227 -14
View File
@@ -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 };
}
/**
+11 -3
View File
@@ -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')
+24 -2
View File
@@ -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}`;
}
-11
View File
@@ -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',
+41
View File
@@ -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 });
},
});
+11 -8
View File
@@ -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,
+1 -1
View File
@@ -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,
+41 -15
View File
@@ -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<typeof READOUT_WORDS>} [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(' · ');
}
+5 -2
View File
@@ -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
+3 -2
View File
@@ -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.',
+26 -3
View File
@@ -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)}`,
},
]
: []),
+33 -6
View File
@@ -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,
);
});
+24 -23
View File
@@ -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;
}
+3 -2
View File
@@ -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', {
+51 -1
View File
@@ -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;
}
+4
View File
@@ -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,
+25
View File
@@ -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
+18 -2
View File
@@ -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;
}
+80 -4
View File
@@ -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';
}
+6 -4
View File
@@ -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}` : '';
+6 -3
View File
@@ -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}`;
}
+14 -1
View File
@@ -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
+5 -3
View File
@@ -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 `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${formatCompassDivision(division)}</span>`;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
})
.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}`;
}
+38 -5
View File
@@ -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(
+47 -1
View File
@@ -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') {
+10
View File
@@ -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,
+14
View File
@@ -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);
}
+14
View File
@@ -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');
}
});
+7 -1
View File
@@ -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) {
+9 -1
View File
@@ -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') {
+53 -14
View File
@@ -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) => {
+23 -11
View File
@@ -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']);
+3 -1
View File
@@ -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);
}
+14 -5
View File
@@ -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);
+59 -2
View File
@@ -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();
+16 -1
View File
@@ -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) {
+81 -26
View File
@@ -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;
+14 -2
View File
@@ -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;
}
+21 -1
View File
@@ -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;
+51
View File
@@ -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);
}
+6
View File
@@ -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 {
+25 -6
View File
@@ -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 {
+3 -1
View File
@@ -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,
+27 -4
View File
@@ -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. */
+31 -6
View File
@@ -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 {
+14 -2
View File
@@ -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);
+58
View File
@@ -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;
}
+11 -1
View File
@@ -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;
+49
View File
@@ -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]}`;
}
+46
View File
@@ -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');
}
});
+26 -1
View File
@@ -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 = `
<div class="gev-voice-heading">
<div class="gev-voice-kicker">${t('AI AGENT')}</div>
<div id="gev-voice-status">${t('OFF')}</div>
<div id="gev-voice-status">${voiceStatusLabel('idle')}</div>
<div class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="${t('Voice model tier — applies next session')}">${t('STD')}</button>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="${t('Estimated session cost')}">~$0.00</span>
+11 -11
View File
@@ -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
+2 -2
View File
@@ -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,
+5 -15
View File
@@ -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.
+11 -11
View File
@@ -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: {