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