From 54dce5b3bdf7f7526f3360eb2f3ae250dfb6a37f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 13 Oct 2026 19:13:12 +0400 Subject: [PATCH] fix(i18n): second screenshot review: remaining English, numbers and wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the re-shot Russian screenshots: - Translated: weather frame/imagery status errors, HUD summary provenance ("НЕДОСТУПНО: СУДА AIS ОНЛАЙН"), Bhote Koshi map labels, cockpit signal distances (КМ). The HUD summary fields are separated with " · " in every language, so the style name and view band no longer read as one phrase. - Numbers follow the locale: cockpit altitude ("26 175"), precipitation. - No breaks inside codes and dates: ADS-B and similar in layer rows, Sentinel-2, cyclone issue dates (word joiner; the UI font has no U+2011), no-break space after label colons in cyclone details. - Layout: count lines under ALPR, short unavailable chip (context "status chip"), roomier draw segments, tighter CCTV calibration chips, two-line beat titles and media card titles, wrapping weather status. - Wording: ru advisory is «сводка» (was «бюллетень»; "Advisory center" is «Центр циклона по сводке»), Street View is «Просмотр улиц», OPACITY «НЕПРОЗР.», synced «синхр.», Area «Зона»; the import dialog no longer says "this scene" for several scenes. Glossary updated. Co-Authored-By: Claude Opus 5.5 --- docs/translations/ru.md | 71 ++-- src/cockpitSignalFocus.test.mjs | 259 ++++++++++--- src/data/bhoteKoshiLocator.js | 24 +- src/data/bhoteKoshiLocator.test.mjs | 529 +++++++++++++++++++++------ src/hud.js | 38 +- src/hudSummaryResponse.js | 12 +- src/hudSummaryResponse.test.mjs | 150 ++++++-- src/i18n/locales/es.json | 22 +- src/i18n/locales/ru.json | 52 ++- src/layers/cyclones/index.js | 5 +- src/layers/cyclones/index.test.mjs | 3 +- src/layers/recentImagery/display.js | 5 +- src/layers/weather/imageryHost.js | 6 +- src/layers/weather/index.js | 27 +- src/layers/weather/rendering.js | 7 +- src/layers/weather/shellRendering.js | 3 +- src/layers/wind/index.js | 13 +- src/layers/wind/rendering.js | 7 +- src/scenes/sharing.js | 2 +- src/ui/cockpitBriefing.js | 8 +- src/ui/cockpitContext.js | 8 +- src/ui/cockpitInstruments.js | 4 +- src/ui/cockpitPresentation.js | 27 +- src/ui/cockpitSignals.js | 3 +- src/ui/layerPanel.js | 97 ++++- src/ui/layerPanel.test.mjs | 69 ++++ src/ui/styles/bhote-koshi.css | 22 +- src/ui/styles/cctv.css | 12 +- src/ui/styles/cockpit.css | 3 + src/ui/styles/controls.css | 5 + src/ui/styles/radio.css | 52 ++- src/ui/styles/weather.css | 5 +- 32 files changed, 1204 insertions(+), 346 deletions(-) diff --git a/docs/translations/ru.md b/docs/translations/ru.md index 8dda853ec..1c540c585 100644 --- a/docs/translations/ru.md +++ b/docs/translations/ru.md @@ -25,37 +25,40 @@ When a word next to a number cannot agree in every form, move the number after a ## Glossary -| English | Русский | -| ------------------------------------------ | -------------------------------------------------- | -| layer / data layer | слой / слой данных | -| feed | поток данных (short: поток) | -| source / data source | источник / источник данных | -| LIVE (status) / live data | ОНЛАЙН / в реальном времени | -| SIMULATED | СИМУЛЯЦИЯ | -| stale / degraded / unavailable | устарел / с перебоями / недоступен | -| retry | повтор | -| key (API key) / POWER UP | ключ / ЗАПУСК | -| Provider Settings | Настройки провайдеров | -| globe / map / map source | глобус / карта / источник карты | -| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы | -| vessel, ship / vessels | судно / суда | -| satellite | спутник | -| launch (rocket) / space mission | пуск / космическая миссия | -| contact / tracking | контакт / отслеживание | -| installation, site | объект | -| camera / CCTV camera | камера / камера CCTV | -| earthquake / active fire | землетрясение / активный пожар | -| scene / shot / scene recipe | сцена / кадр / рецепт сцены | -| briefing / panel / dock | брифинг / панель / док | -| preset / visual preset | пресет / визуальный пресет | -| Scope (circular viewport mask) | прицел | -| heading / altitude / ground speed | курс / высота / путевая скорость | -| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. | -| PREV / NEXT | ПРЕД. / СЛЕД. | -| Previous / Next (button text) | Назад / Далее | -| anchor / data pack / bundle (scene) | якорь / пакет данных / архив | -| basemap / box (Recent Imagery area) | подложка / область | -| camera move | перелёт | -| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. | -| compass points (N, NNE, …) | С, ССВ, … | -| L / R (relative bearing) | Л / П | +| English | Русский | +| ------------------------------------------ | ------------------------------------------------------------------ | +| layer / data layer | слой / слой данных | +| feed | поток данных (short: поток) | +| source / data source | источник / источник данных | +| LIVE (status) / live data | ОНЛАЙН / в реальном времени | +| SIMULATED | СИМУЛЯЦИЯ | +| stale / degraded / unavailable | устарел / с перебоями / недоступен | +| retry | повтор | +| key (API key) / POWER UP | ключ / ЗАПУСК | +| Provider Settings | Настройки провайдеров | +| globe / map / map source | глобус / карта / источник карты | +| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы | +| vessel, ship / vessels | судно / суда | +| satellite | спутник | +| launch (rocket) / space mission | пуск / космическая миссия | +| contact / tracking | контакт / отслеживание | +| installation, site | объект | +| camera / CCTV camera | камера / камера CCTV | +| earthquake / active fire | землетрясение / активный пожар | +| scene / shot / scene recipe | сцена / кадр / рецепт сцены | +| briefing / panel / dock | брифинг / панель / док | +| preset / visual preset | пресет / визуальный пресет | +| Scope (circular viewport mask) | прицел | +| heading / altitude / ground speed | курс / высота / путевая скорость | +| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. | +| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке | +| Street View (Google) | Просмотр улиц (как в русских Google Картах) | +| CONUS (NOAA region code) | CONUS, без перевода | +| PREV / NEXT | ПРЕД. / СЛЕД. | +| Previous / Next (button text) | Назад / Далее | +| anchor / data pack / bundle (scene) | якорь / пакет данных / архив | +| basemap / box (Recent Imagery area) | подложка / область | +| camera move | перелёт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. | +| compass points (N, NNE, …) | С, ССВ, … | +| L / R (relative bearing) | Л / П | diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index 57e9ecd6f..cfe60a461 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -4,7 +4,9 @@ import { afterEach } from 'node:test'; import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js'; import { CockpitViewController } from './ui/cockpitController.js'; const previousDocument = globalThis.document; -afterEach(() => { globalThis.document = previousDocument; }); +afterEach(() => { + globalThis.document = previousDocument; +}); // Model native focus loss on removal AND on ordinary DOM moves. Keeping an // object reference alone is insufficient if reconciliation disconnects it. @@ -22,23 +24,60 @@ function fixture() { this.className = ''; this._text = ''; this.classList = { - add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); }, + add: (...names) => { + this.className = [ + ...new Set([ + ...this.className.split(/\s+/).filter(Boolean), + ...names, + ]), + ].join(' '); + }, }; } - get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); } - get firstElementChild() { return this.children[0] || null; } - get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; } - get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); } - set textContent(value) { this.replaceChildren(); this._text = String(value); } - setAttribute(name, value) { this.attributes.set(name, String(value)); } - getAttribute(name) { return this.attributes.get(name) ?? null; } - contains(node) { return this === node || this.children.some((child) => child.contains(node)); } - append(...nodes) { for (const node of nodes) this.insertBefore(node, null); } + get isConnected() { + return this === document.body || Boolean(this.parentNode?.isConnected); + } + get firstElementChild() { + return this.children[0] || null; + } + get nextElementSibling() { + return ( + this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || + null + ); + } + get textContent() { + return ( + this._text + this.children.map((node) => node.textContent).join('') + ); + } + set textContent(value) { + this.replaceChildren(); + this._text = String(value); + } + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + append(...nodes) { + for (const node of nodes) this.insertBefore(node, null); + } insertBefore(node, anchor) { - assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent'); + assert.ok( + anchor === null || anchor.parentNode === this, + 'insert anchor belongs to its parent', + ); if (node === anchor) return node; node.remove(); - const index = anchor === null ? this.children.length : this.children.indexOf(anchor); + const index = + anchor === null ? this.children.length : this.children.indexOf(anchor); this.children.splice(index, 0, node); node.parentNode = this; return node; @@ -49,7 +88,10 @@ function fixture() { document.activeElement = document.body; focusLosses += 1; } - this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode.children.splice( + this.parentNode.children.indexOf(this), + 1, + ); this.parentNode = null; } replaceChildren(...nodes) { @@ -59,20 +101,32 @@ function fixture() { } matches(selector) { if (selector === 'button[data-signal-layer][data-signal-id]') { - return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null; + return ( + this.tagName === 'BUTTON' && + this.dataset.signalLayer != null && + this.dataset.signalId != null + ); } - return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1)) + return selector.startsWith('.') + ? this.className.split(/\s+/).includes(selector.slice(1)) : this.tagName === selector.toUpperCase(); } closest(selector) { - for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node; + for (let node = this; node; node = node.parentNode) + if (node.matches(selector)) return node; return null; } querySelectorAll(selector) { - return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]); + return this.children.flatMap((child) => [ + ...(child.matches(selector) ? [child] : []), + ...child.querySelectorAll(selector), + ]); } focus() { - assert.ok(this.isConnected, 'a focus destination must still be connected'); + assert.ok( + this.isConnected, + 'a focus destination must still be connected', + ); focusCalls += 1; document.activeElement = this; } @@ -85,7 +139,11 @@ function fixture() { controller.services = { formatAwarenessLabel }; controller.signalList = new Node('ol'); controller.signalToggle = new Node('button'); - controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')]; + controller.briefTabs = [ + new Node('button'), + new Node('button'), + new Node('button'), + ]; controller.briefPageIndex = 0; controller.signalItems = []; controller.signalSignatures = new Map(); @@ -93,64 +151,131 @@ function fixture() { const display = new Node('button'); const radio = new Node('button'); const outside = new Node('button'); - document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside); + document.body.append( + controller.signalToggle, + controller.signalList, + ...controller.briefTabs, + display, + radio, + outside, + ); const selected = []; - controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) }; - const click = event => controller.handleSignalClick(event); + controller.services.militaryAwarenessLayer = { + focusTarget: (...args) => selected.push(args), + }; + const click = (event) => controller.handleSignalClick(event); const buttons = () => controller.signalList.querySelectorAll('button'); const tab = () => { const nodes = document.body.querySelectorAll('button'); const index = nodes.indexOf(document.activeElement); nodes[(index + 1) % nodes.length].focus(); }; - return { controller, document, buttons, display, radio, outside, selected, click, tab, - losses: () => focusLosses, focusCalls: () => focusCalls }; + return { + controller, + document, + buttons, + display, + radio, + outside, + selected, + click, + tab, + losses: () => focusLosses, + focusCalls: () => focusCalls, + }; } const item = (id, overrides = {}) => ({ - key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM', - target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides, + key: `flight:flights:${id}`, + tone: 'nearby', + title: `Flight ${id}`, + detail: 'COMMERCIAL FLIGHT · 2 KM', + target: { layerId: 'flights', id }, + timestamp: 1_700_000_000_000, + ...overrides, }); -const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); }; +const render = (f, items) => { + f.controller.signalItems = items; + f.controller.renderCockpitSignals(); +}; const snapshot = (distanceM = 2000) => ({ evaluatedAt: 1_700_000_000_000, subject: { layerId: 'flights', id: 'current', label: 'CURRENT' }, - cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }], + cohorts: [ + { + id: 'flights', + count: 1, + nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }], + }, + ], }); test('actual Context refresh retains the focused signal button across unchanged and changed details', () => { const f = fixture(); f.controller.updateCockpitSignals(snapshot(), 0); - const target = f.buttons()[1]; target.focus(); + const target = f.buttons()[1]; + target.focus(); const calls = f.focusCalls(); - for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0); + for (let tick = 0; tick < 8; tick += 1) + f.controller.updateCockpitSignals(snapshot(2500), 0); assert.equal(f.buttons()[1], target); assert.equal(f.document.activeElement, target); assert.equal(f.losses(), 0); - assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it'); - assert.match(f.controller.signalList.textContent, /2\.5 KM/); + assert.equal( + f.focusCalls(), + calls, + 'refresh must preserve focus without repeatedly restoring it', + ); + assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/); }); test('signal content and accessible name update without replacing the active target', () => { - const f = fixture(); render(f, [item('a')]); - const target = f.buttons()[0]; target.focus(); - render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]); + const f = fixture(); + render(f, [item('a')]); + const target = f.buttons()[0]; + target.focus(); + render(f, [ + item('a', { + title: 'RENAMED', + tone: 'track', + detail: 'CURRENT', + timestamp: 1_700_000_001_000, + }), + ]); assert.equal(f.buttons()[0], target); assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED'); assert.match(target.textContent, /RENAMED/); assert.match(f.controller.signalList.textContent, /CURRENT/); - assert.equal(f.controller.signalList.children[0].className, 'track actionable'); + assert.equal( + f.controller.signalList.children[0].className, + 'track actionable', + ); assert.equal(f.document.activeElement, target); assert.equal(f.losses(), 0); }); test('signal ranking changes move surrounding rows without disconnecting the focused row', () => { - const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id))); - const target = f.buttons()[1]; target.focus(); + const f = fixture(); + render( + f, + ['a', 'b', 'c', 'd'].map((id) => item(id)), + ); + const target = f.buttons()[1]; + target.focus(); const calls = f.focusCalls(); - for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) { - render(f, order.map((id) => item(id))); - assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order); + for (const order of [ + ['d', 'c', 'b', 'a'], + ['b', 'c', 'd', 'a'], + ['a', 'd', 'c', 'b'], + ]) { + render( + f, + order.map((id) => item(id)), + ); + assert.deepEqual( + f.buttons().map((button) => button.dataset.signalId), + order, + ); assert.equal(f.document.activeElement, target); assert.equal(f.losses(), 0); } @@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc }); test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => { - const f = fixture(); render(f, [item('a'), item('b')]); - const target = f.buttons()[1]; target.focus(); - f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE'); + const f = fixture(); + render(f, [item('a'), item('b')]); + const target = f.buttons()[1]; + target.focus(); + f.controller.pushCockpitSignal( + 'status', + 'warning', + 'INPUT UNKNOWN', + 'SOURCE UNAVAILABLE', + ); assert.equal(f.document.activeElement, target); assert.equal(f.losses(), 0); - const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } }; + const event = { + target: target.children[0], + prevented: false, + preventDefault() { + this.prevented = true; + }, + }; f.click(event); assert.equal(event.prevented, true); assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]); }); test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => { - const f = fixture(); render(f, [item('a'), item('b')]); - const removed = f.buttons()[1]; removed.focus(); + const f = fixture(); + render(f, [item('a'), item('b')]); + const removed = f.buttons()[1]; + removed.focus(); const calls = f.focusCalls(); render(f, [item('a')]); assert.equal(removed.isConnected, false); assert.equal(f.document.activeElement, f.controller.briefTabs[0]); assert.equal(f.focusCalls(), calls + 1); - f.tab(); f.tab(); f.tab(); + f.tab(); + f.tab(); + f.tab(); assert.equal(f.document.activeElement, f.display); render(f, [item('a', { detail: 'UPDATED' })]); - assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back'); + assert.equal( + f.document.activeElement, + f.display, + 'later refresh must not pull focus back', + ); f.tab(); assert.equal(f.document.activeElement, f.radio); }); test('a changed target cannot reuse a focused action for another flight', () => { - const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]); - const removed = f.buttons()[0]; removed.focus(); - render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]); + const f = fixture(); + render(f, [item('a', { key: 'shared-status-key' })]); + const removed = f.buttons()[0]; + removed.focus(); + render(f, [ + item('b', { + key: 'shared-status-key', + target: { layerId: 'military', id: 'b' }, + }), + ]); const target = f.buttons()[0]; assert.notEqual(target, removed); assert.equal(removed.isConnected, false); diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 8de0de290..15757b99c 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -12,7 +12,7 @@ import { } from './dataCredits.js'; import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, getLocale } from '../i18n/index.js'; export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = @@ -52,6 +52,26 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8; const TRIGGER_SOURCE_URL = 'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive'; +// Overview pin titles that are more than a place name translate at display, +// through the same entries the event panel uses. The data stays English and +// place names stay Latin; everything else shows as authored. +const PLACE_TITLE_TEXT = new Map( + [ + msg('Collapse viewpoint'), + msg('Dammed lake'), + msg('Second landslide'), + msg('Border gate'), + msg('Dandagaun viewpoint'), + msg('Bidur bridge'), + msg('Devighat bridge'), + ].map((text) => [text.toUpperCase(), text]), +); + +function placeTitle(title) { + const text = PLACE_TITLE_TEXT.get(title); + return text ? t(text).toLocaleUpperCase(getLocale()) : title; +} + const INCIDENT = Object.freeze({ lat: 28.3632333, lon: 85.4423639, @@ -820,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({ position, variant: 'label', paintLane: 'ambient-label', - title: `${String(index + 1).padStart(2, '0')} · ${place.title}`, + title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`, details: [], accent: INCIDENT_ACCENT, sourceAlpha: progress, diff --git a/src/data/bhoteKoshiLocator.test.mjs b/src/data/bhoteKoshiLocator.test.mjs index 4c9a4be91..8af4a2a42 100644 --- a/src/data/bhoteKoshiLocator.test.mjs +++ b/src/data/bhoteKoshiLocator.test.mjs @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFile } from 'node:fs/promises'; -import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; +import { + INCIDENT_OVERVIEW_HOLD_SEC, + INCIDENT_OVERVIEW_PLACES, +} from './bhoteKoshiIncidentPlaces.js'; import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import * as Cesium from 'cesium'; +import { setLocale } from '../i18n/index.js'; import { SceneDirector } from '../scenes/director.js'; import { createPlaybackClock } from '../director/clock.js'; import { createDefaultScenePacks } from '../scenes/packs/defaults.js'; @@ -29,8 +33,12 @@ function viewerFixture() { return { creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} }, dataSources: { - add(source) { added.push(source); }, - remove(source) { removed.push(source); }, + add(source) { + added.push(source); + }, + remove(source) { + removed.push(source); + }, }, _test: { added, removed }, }; @@ -38,7 +46,9 @@ function viewerFixture() { function nonRunningAnimation() { return { - scheduleFrame() { return 1; }, + scheduleFrame() { + return 1; + }, cancelFrame() {}, }; } @@ -58,24 +68,68 @@ function overlayHostFixture() { } test('Bhote Koshi locator closes valid region rings once', () => { - assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [ - [80, 26], [88, 26], [88, 30], [80, 26], - ]); - assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [ - [80, 26], [88, 26], [80, 26], - ]); - assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []); + assert.deepEqual( + closeRegionRing([ + [80, 26], + [88, 26], + [88, 30], + ]), + [ + [80, 26], + [88, 26], + [88, 30], + [80, 26], + ], + ); + assert.deepEqual( + closeRegionRing([ + [80, 26], + [88, 26], + [80, 26], + ]), + [ + [80, 26], + [88, 26], + [80, 26], + ], + ); + assert.deepEqual( + closeRegionRing([ + [80, 26], + ['bad', 27], + ]), + [], + ); }); test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => { - const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]]; - const island = [[82, 25], [83, 25], [82, 25]]; - assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main); - assert.equal(largestGeoJsonRing({ - type: 'MultiPolygon', - coordinates: [[island], [main]], - }), main); - assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []); + const main = [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + [80, 26], + ]; + const island = [ + [82, 25], + [83, 25], + [82, 25], + ]; + assert.equal( + largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), + main, + ); + assert.equal( + largestGeoJsonRing({ + type: 'MultiPolygon', + coordinates: [[island], [main]], + }), + main, + ); + assert.deepEqual( + largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), + [], + ); }); test('Bhote Koshi locator reveals a border progressively', () => { @@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => { new Cesium.Cartesian3(10, 0, 0), new Cesium.Cartesian3(20, 0, 0), ]; - assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]); + assert.deepEqual(revealPolylinePositions(positions, 0), [ + positions[0], + positions[0], + ]); assert.deepEqual(revealPolylinePositions(positions, 0.25), [ positions[0], new Cesium.Cartesian3(5, 0, 0), @@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident', const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', - ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + ring: [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + ], }), requestRender: (reason) => renders.push(reason), overlayHost, @@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident', const publication = overlayHost._test.publications.at(-1); assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); assert.equal(publication.entries.length, 2); - const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout'); - const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout'); + const nepal = publication.entries.find( + ({ id }) => id === 'nepal-context-callout', + ); + const incident = publication.entries.find( + ({ id }) => id === 'bhote-koshi-incident-callout', + ); assert.equal(nepal.variant, 'label'); assert.equal(nepal.title, 'NEPAL'); assert.equal(nepal.anchorDot, true); @@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident', assert.equal(incident.contentAlpha(), 1); assert.equal(incident.presentationScale(), 1); assert.equal(layer.getStats().status, 'nominal'); - assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); + assert.deepEqual(layer.getParams(), { + presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION, + }); assert.ok(renders.includes('bhote-koshi-locator-boundary')); assert.equal(await layer.disable(), true); assert.equal(viewer._test.removed.length, 1); - assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID); + assert.equal( + overlayHost._test.clears.at(-1), + BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID, + ); assert.equal(layer.getStats().status, 'idle'); }); @@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', - ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + ring: [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + ], }), requestRender() {}, overlayHost, @@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION }); await layer.enable(viewer); - assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [ - 'nepal-context-halo', - ]); + assert.deepEqual( + viewer._test.added[0].entities.values.map((entity) => entity.id), + ['nepal-context-halo'], + ); const nepalLabel = overlayHost._test.publications.at(-1).entries[0]; assert.equal(nepalLabel.variant, 'label'); assert.equal(nepalLabel.placement, 'right'); assert.equal(nepalLabel.anchorDot, true); assert.equal(layer.getStats().coverage, 'Nepal context'); - assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [ - { id: 'nepal-context', active: true }, - { id: 'bhote-koshi-regional', active: false }, - { id: 'bhote-koshi-city-context', active: false }, - { id: 'bhote-koshi-incident-places', active: false }, - { id: 'bhote-koshi-flood-path', active: false }, - { id: 'bhote-koshi-path-overview', active: false }, - { id: 'bhote-koshi-trigger-record', active: false }, - ]); + assert.deepEqual( + layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), + [ + { id: 'nepal-context', active: true }, + { id: 'bhote-koshi-regional', active: false }, + { id: 'bhote-koshi-city-context', active: false }, + { id: 'bhote-koshi-incident-places', active: false }, + { id: 'bhote-koshi-flood-path', active: false }, + { id: 'bhote-koshi-path-overview', active: false }, + { id: 'bhote-koshi-trigger-record', active: false }, + ], + ); layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight')); - assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined); assert.equal( - overlayHost._test.publications.at(-1).entries - .find(({ id }) => id === 'bhote-koshi-incident-callout').variant, + viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), + undefined, + ); + assert.equal( + overlayHost._test.publications + .at(-1) + .entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant, 'card', ); layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION }); - assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [ - 'nepal-context-halo', - ]); + assert.deepEqual( + viewer._test.added[0].entities.values.map((entity) => entity.id), + ['nepal-context-halo'], + ); await layer.destroy(); }); @@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ let nowMs = 0; let pulseFrame = null; const layer = createBhoteKoshiLocatorLayer({ - boundaryResolver: async () => null, requestRender() {}, + boundaryResolver: async () => null, + requestRender() {}, overlayHost, now: () => nowMs, - scheduleFrame(callback) { pulseFrame = callback; return 1; }, + scheduleFrame(callback) { + pulseFrame = callback; + return 1; + }, cancelFrame() {}, }); layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION }); await layer.enable(viewer); const source = viewer._test.added[0]; - const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8')); + const event = JSON.parse( + await readFile( + new URL( + '../../public/events/bhote-koshi-2026/event.json', + import.meta.url, + ), + 'utf8', + ), + ); assert.equal(event.evidenceSpine.length, 16); assert.equal(event.evidenceSpine.at(-1).id, 'charaudi'); assert.equal(source.entities.values.length, 15); @@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ const id = `bhote-koshi-place-${point.id}`; const pin = labels.find((entry) => entry.id === id); assert.ok(pin, point.id); - assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`); + assert.equal( + pin.title, + `${String(index + 1).padStart(2, '0')} · ${point.title}`, + ); assert.equal(pin.variant, 'label'); const location = Cesium.Cartographic.fromCartesian(pin.position); - assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6); - assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6); + assert.ok( + Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6, + ); nowMs = 349 + index * 650; assert.equal(pin.sourceAlpha(), 0); nowMs += 1; assert.equal(pin.sourceAlpha(), 0); - assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1); + assert.equal( + source.entities.values.filter( + (entity) => entity.id.endsWith('-halo') && entity.show.getValue(), + ).length, + 1, + ); nowMs += 520; assert.equal(pin.sourceAlpha(), 1); } assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000); // A repeat halo cycle must not erase already revealed labels. nowMs = 350 + 15 * 650; - assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true); - assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1); - assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false); + assert.equal( + source.entities + .getById('bhote-koshi-place-immediate-collapse-viewpoint-halo') + .show.getValue(), + true, + ); + assert.equal( + labels + .find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge') + .sourceAlpha(), + 1, + ); + assert.equal( + labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), + false, + ); assert.equal(typeof pulseFrame, 'function'); pulseFrame(); assert.equal(overlayHost._test.publications.at(-1).options.moving, false); layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION }); - assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false); + assert.equal( + source.entities.values.some((entity) => + entity.id.startsWith('bhote-koshi-place-'), + ), + false, + ); + await layer.destroy(); +}); + +test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => { + t.after(() => setLocale('en')); + setLocale('ru'); + const viewer = viewerFixture(); + const overlayHost = overlayHostFixture(); + const layer = createBhoteKoshiLocatorLayer({ + boundaryResolver: async () => null, + requestRender() {}, + overlayHost, + now: () => 0, + ...nonRunningAnimation(), + }); + + layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }); + await layer.enable(viewer); + const titles = new Map( + overlayHost._test.publications + .at(-1) + .entries.map(({ id, title }) => [id, title]), + ); + assert.equal( + titles.get('bhote-koshi-place-immediate-collapse-viewpoint'), + '01 · ОБЗОР ОБРУШЕНИЯ', + ); + assert.equal( + titles.get('bhote-koshi-place-gyirong-border-gate'), + '04 · ПОГРАНПЕРЕХОД', + ); + assert.equal( + titles.get('bhote-koshi-place-dandagaun-viewpoint'), + '11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN', + ); + assert.equal( + titles.get('bhote-koshi-place-bidur-trishuli-bridge'), + '14 · МОСТ BIDUR', + ); + assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE'); await layer.destroy(); }); @@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh const source = viewer._test.added[0]; const path = source.entities.getById('bhote-koshi-flood-path'); const labels = overlayHost._test.publications.at(-1).entries; - const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint'); - const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge'); + const trigger = labels.find( + ({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint', + ); + const bidur = labels.find( + ({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge', + ); assert.equal(source.entities.values.length, 1); assert.equal(labels.length, 15); @@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh } assert.equal(path.polyline.clampToGround.getValue(), true); assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty); - assert.ok(path.polyline.material.color.getValue().equalsEpsilon( - Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96), - Cesium.Math.EPSILON7, - )); + assert.ok( + path.polyline.material.color + .getValue() + .equalsEpsilon( + Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96), + Cesium.Math.EPSILON7, + ), + ); assert.equal(path.show.getValue(), false); assert.equal(trigger.sourceAlpha(), 1); assert.equal(bidur.sourceAlpha(), 1); @@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh const halfwayCount = path.polyline.positions.getValue().length; assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2); nowMs = 4250; - assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length); + assert.equal( + path.polyline.positions.getValue().length, + BHOTE_KOSHI_FLOOD_PATH.length, + ); assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]); // More observation pins must not extend or straighten the sourced route. - assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => ( - Cesium.Cartesian3.fromDegrees(lon, lat) - ))); + assert.deepEqual( + path.polyline.positions.getValue(), + BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => + Cesium.Cartesian3.fromDegrees(lon, lat), + ), + ); assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]); assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path'); - assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION }); + assert.deepEqual(layer.getParams(), { + presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION, + }); await layer.destroy(); }); @@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', - ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + ring: [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + ], }), requestRender() {}, overlayHost, @@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep assert.equal(labels.length, 15); for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) { const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`); - assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`); + assert.equal( + pin.title, + `${String(index + 1).padStart(2, '0')} · ${place.title}`, + ); assert.equal(pin.sourceAlpha(), 1); } assert.equal(path.show.getValue(), true); - assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length); - assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0], - Cesium.Cartesian3.fromDegrees(85.48476, 28.33255))); + assert.equal( + path.polyline.positions.getValue().length, + BHOTE_KOSHI_FLOOD_PATH.length, + ); + assert.ok( + Cesium.Cartesian3.equals( + path.polyline.positions.getValue()[0], + Cesium.Cartesian3.fromDegrees(85.48476, 28.33255), + ), + ); assert.equal(source.entities.getById('nepal-border-fill'), undefined); assert.equal(source.entities.getById('nepal-border-highlight'), undefined); assert.equal( - source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'), + source.entities.getById( + 'bhote-koshi-place-immediate-collapse-viewpoint-halo', + ), undefined, ); assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview'); - assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION }); + assert.deepEqual(layer.getParams(), { + presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION, + }); await layer.destroy(); }); @@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details', const publication = overlayHost._test.publications.at(-1); const record = publication.entries[0]; - assert.deepEqual(source.entities.values.map(({ id }) => id), [ - 'bhote-koshi-flood-path', - 'bhote-koshi-trigger-record-halo', - ]); + assert.deepEqual( + source.entities.values.map(({ id }) => id), + ['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'], + ); assert.equal(record.title, 'USGS INITIAL REPORT'); assert.deepEqual(record.details, [ '26 AUG 2026 · 08:37 NPT', @@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits' viewer.camera = { moveStart, moveEnd, - flyTo(options) { flights.push(options); }, + flyTo(options) { + flights.push(options); + }, cancelFlight() {}, }; const layer = createBhoteKoshiLocatorLayer({ @@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits' assert.equal(flights.length, 1); assert.equal(flights[0].duration, 4.6); const destination = Cesium.Cartographic.fromCartesian(flights[0].destination); - assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6); - assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6, + ); assert.ok(Math.abs(destination.height - 10526) < 1e-4); flights[0].complete(); assert.equal(flights.length, 2); @@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { const moveStart = new Cesium.Event(); const moveEnd = new Cesium.Event(); viewer.camera = { - moveStart, moveEnd, - flyTo(options) { flights.push(options); }, + moveStart, + moveEnd, + flyTo(options) { + flights.push(options); + }, cancelFlight() {}, }; const layer = createBhoteKoshiLocatorLayer({ @@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { timers.set(++nextTimer, callback); return nextTimer; }, - cancelDelay(id) { timers.delete(id); }, + cancelDelay(id) { + timers.delete(id); + }, ...nonRunningAnimation(), }); const director = { - _clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }), + _clock: createPlaybackClock({ + isRunning: () => false, + timingForShot: () => null, + onProgress() {}, + }), _scenePacks: createDefaultScenePacks(), dataManager: { layers: new Map([[layer.id, { module: layer }]]) }, viewer, _sceneSeekGeneration: 0, _loadGeneration: 0, - _cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel, + _cancelActiveSceneTravel: + SceneDirector.prototype._cancelActiveSceneTravel, _setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback, }; try { - layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); + layer.setParams({ + presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + }); await layer.enable(viewer); if (phase === 'move-end') moveStart.raiseEvent(); if (phase === 'approach' || phase === 'orbit') callbacks[0](); @@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) { callbacks[0](); moveEnd.raiseEvent(); for (const flight of oldFlights) flight.complete(); - assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit'); - assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator'); + assert.equal( + flights.length, + oldFlights.length, + 'no post-Stop approach or orbit', + ); + assert.equal( + layer.getStats().count, + 1, + 'Stop preserves the visible locator', + ); - layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION }); + layer.setParams({ + presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION, + }); callbacks.at(-1)(); const successor = flights.at(-1); const count = flights.length; callbacks[0](); - for (const flight of oldFlights) { flight.cancel(); flight.complete(); } + for (const flight of oldFlights) { + flight.cancel(); + flight.complete(); + } assert.equal(flights.length, count, 'old callbacks cannot add a flight'); successor.complete(); - assert.equal(flights.length, count + 1, 'successor retains its own orbit'); - } finally { await layer.destroy(); } + assert.equal( + flights.length, + count + 1, + 'successor retains its own orbit', + ); + } finally { + await layer.destroy(); + } }); } @@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', - ring: [[80, 26], [88, 26], [88, 30], [80, 30]], + ring: [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + ], }), requestRender() {}, overlayHost, @@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities const source = viewer._test.added[0]; const borderFill = source.entities.getById('nepal-border-fill'); const entries = overlayHost._test.publications.at(-1).entries; - const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout'); - const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu'); - const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur'); - const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel'); + const incident = entries.find( + ({ id }) => id === 'bhote-koshi-incident-callout', + ); + const kathmandu = entries.find( + ({ id }) => id === 'bhote-koshi-city-kathmandu', + ); + const bhaktapur = entries.find( + ({ id }) => id === 'bhote-koshi-city-bhaktapur', + ); + const dhulikhel = entries.find( + ({ id }) => id === 'bhote-koshi-city-dhulikhel', + ); assert.equal(borderFill.show.getValue(), true); assert.equal(incident.contentAlpha(), 1); assert.equal(kathmandu.title, 'KATHMANDU'); assert.equal(bhaktapur.title, 'BHAKTAPUR'); assert.equal(dhulikhel.title, 'DHULIKHEL'); - assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => ( - variant === 'label' && anchorDot === true - ))); + assert.ok( + [kathmandu, bhaktapur, dhulikhel].every( + ({ variant, anchorDot }) => variant === 'label' && anchorDot === true, + ), + ); assert.equal(kathmandu.sourceAlpha(), 0); assert.equal(bhaktapur.sourceAlpha(), 0); assert.equal(dhulikhel.sourceAlpha(), 0); @@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities nowMs = 1300; assert.equal(dhulikhel.sourceAlpha(), 1); assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities'); - assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION }); + assert.deepEqual(layer.getParams(), { + presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION, + }); await layer.destroy(); }); @@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava }); try { await layer.enable(viewer); - const ids = viewer._test.added[0].entities.values.map((entity) => entity.id); + const ids = viewer._test.added[0].entities.values.map( + (entity) => entity.id, + ); assert.ok(ids.includes('nepal-context-halo')); assert.ok(ids.includes('bhote-koshi-incident-halo')); assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false); assert.equal(ids.includes('nepal-border-highlight'), false); - assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [ - 'nepal-context-callout', - 'bhote-koshi-incident-callout', - ]); + assert.deepEqual( + overlayHost._test.publications.at(-1).entries.map(({ id }) => id), + ['nepal-context-callout', 'bhote-koshi-incident-callout'], + ); assert.equal(layer.getStats().status, 'degraded'); } finally { console.warn = originalWarn; @@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu const viewer = viewerFixture(); const overlayHost = overlayHostFixture(); let releaseBoundary; - const boundary = new Promise((resolve) => { releaseBoundary = resolve; }); + const boundary = new Promise((resolve) => { + releaseBoundary = resolve; + }); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => boundary, requestRender() {}, @@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu const enabling = layer.enable(viewer); assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo')); - assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined); - assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined); + assert.equal( + viewer._test.added[0].entities.getById('nepal-border-highlight'), + undefined, + ); + assert.equal( + viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), + undefined, + ); assert.equal(overlayHost._test.publications.length, 1); - assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [ - 'nepal-context-callout', - ]); + assert.deepEqual( + overlayHost._test.publications[0].entries.map(({ id }) => id), + ['nepal-context-callout'], + ); - releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }); + releaseBoundary({ + ring: [ + [80, 26], + [88, 26], + [88, 30], + [80, 30], + ], + }); await enabling; - assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo')); + assert.ok( + viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), + ); assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight')); - assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined); + assert.equal( + viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), + undefined, + ); assert.equal(overlayHost._test.publications.length, 2); - assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [ - 'nepal-context-callout', - 'bhote-koshi-incident-callout', - ]); + assert.deepEqual( + overlayHost._test.publications.at(-1).entries.map(({ id }) => id), + ['nepal-context-callout', 'bhote-koshi-incident-callout'], + ); await layer.destroy(); }); @@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su const viewer = viewerFixture(); const controller = new AbortController(); let releaseBoundary; - const boundary = new Promise((resolve) => { releaseBoundary = resolve; }); + const boundary = new Promise((resolve) => { + releaseBoundary = resolve; + }); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => boundary, requestRender() {}, @@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su }); const enabling = layer.enable(viewer, { signal: controller.signal }); controller.abort(); - releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] }); + releaseBoundary({ + ring: [ + [80, 26], + [88, 26], + [88, 30], + ], + }); await assert.rejects(enabling, (error) => error?.name === 'AbortError'); assert.equal(viewer._test.removed.length, 1); }); diff --git a/src/hud.js b/src/hud.js index 8e779575c..6c157ddd3 100644 --- a/src/hud.js +++ b/src/hud.js @@ -27,7 +27,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js'; import { hudSummaryMatchesProvenance, hudSummaryLayerContext, - hudTelemetryProvenanceTag, + hudTelemetryProvenance, isHudSummaryUnconfigured, } from './hudSummaryResponse.js'; import { @@ -37,6 +37,7 @@ import { } from './hudLayouts.js'; import { formatNumber, msg, t } from './i18n/index.js'; import { unitLabel } from './ui/unitLabels.js'; +import { localizeLayerName } from './ui/layerPanel.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -91,6 +92,35 @@ const REGION_LABELS = Object.freeze({ 'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'), }); +/** Feed-state ids from hudTelemetryProvenance() and the word shown for each. */ +const FEED_STATE_LABELS = Object.freeze({ + loading: msg('LOADING'), + degraded: msg('DEGRADED'), + stale: msg('STALE'), + partial: msg('PARTIAL'), + fallback: msg('FALLBACK'), + unavailable: msg('UNAVAILABLE'), +}); + +/** + * The summary's feed-state suffix in the active language, e.g. + * `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in + * layer data; only this line translates them. + */ +function localizedProvenanceTag(provenance) { + if (!provenance) return null; + const state = FEED_STATE_LABELS[provenance.state] + ? t(FEED_STATE_LABELS[provenance.state]) + : provenance.state.toUpperCase(); + if (!provenance.names.length) return state; + return t('{state}: {layers}', { + state, + layers: provenance.names + .map((name) => localizeLayerName(name).toUpperCase()) + .join('/'), + }); +} + /** The name shown for a _viewBand() id. */ function viewBandLabel(band) { switch (band) { @@ -751,12 +781,12 @@ export class IntelHUD { LOCALIZED_LOCALITY, ); - const provenance = hudTelemetryProvenanceTag( - this._dataManager?.getAll?.() || [], + const provenance = localizedProvenanceTag( + hudTelemetryProvenance(this._dataManager?.getAll?.() || []), ); // 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} · {band} · {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}', { mode: modeLabel, band: viewBandLabel(band), diff --git a/src/hudSummaryResponse.js b/src/hudSummaryResponse.js index 82b12e55f..82a5c2a23 100644 --- a/src/hudSummaryResponse.js +++ b/src/hudSummaryResponse.js @@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) { } /** - * Deterministic telemetry suffix when an enabled feed is not nominal. + * The feed state and up to two layer names for the HUD telemetry suffix when + * an enabled feed is not nominal. Values stay English (`state` is a feed-state + * id, `names` are `layer.name`); the HUD translates them for display. * @param {Array} [layers] * @param {{ now?: number }} [options] - * @returns {string|null} + * @returns {{ state: string, names: string[] }|null} */ -export function hudTelemetryProvenanceTag(layers = [], options) { +export function hudTelemetryProvenance(layers = [], options) { const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled); const envelope = feedProvenanceEnvelope(snapshots, options); if (!envelope.overall || envelope.overall === 'nominal') return null; @@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) { .filter( (s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off', ) - .map((s) => String(s.name || s.id || 'LAYER').toUpperCase()) + .map((s) => String(s.name || s.id || 'LAYER')) .slice(0, 2); - return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`; + return { state: envelope.overall, names }; } /** Require the supplied non-nominal state before showing an AI summary. */ diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index 194d66106..d7da91129 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -5,7 +5,7 @@ import { HUD_SUMMARY_INSTRUCTIONS, HUD_SUMMARY_UNCONFIGURED_CODE, hudSummaryLayerContext, - hudTelemetryProvenanceTag, + hudTelemetryProvenance, isHudSummaryUnconfigured, keylessHudSummaryResponse, } from './hudSummaryResponse.js'; @@ -30,7 +30,10 @@ function installOpenAiRoutes() { return routes; } -function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) { +function invokeRoute( + handler, + { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}, +) { return new Promise((resolve, reject) => { const headers = new Map(); const req = { @@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => { test('recognizes only the exact deliberate no-key fallback response', () => { assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true); assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false); - assert.equal(isHudSummaryUnconfigured(200, { - ...UNCONFIGURED_PAYLOAD, - error: 'provider failed', - }), false); - assert.equal(isHudSummaryUnconfigured(200, { - ...UNCONFIGURED_PAYLOAD, - summary: 'Unexpected provider output', - }), false); - assert.equal(isHudSummaryUnconfigured(200, { - ...UNCONFIGURED_PAYLOAD, - configured: true, - }), false); - assert.equal(isHudSummaryUnconfigured(200, { - ...UNCONFIGURED_PAYLOAD, - unexpected: true, - }), false); - assert.equal(isHudSummaryUnconfigured(200, { - code: HUD_SUMMARY_UNCONFIGURED_CODE, - }), false); + assert.equal( + isHudSummaryUnconfigured(200, { + ...UNCONFIGURED_PAYLOAD, + error: 'provider failed', + }), + false, + ); + assert.equal( + isHudSummaryUnconfigured(200, { + ...UNCONFIGURED_PAYLOAD, + summary: 'Unexpected provider output', + }), + false, + ); + assert.equal( + isHudSummaryUnconfigured(200, { + ...UNCONFIGURED_PAYLOAD, + configured: true, + }), + false, + ); + assert.equal( + isHudSummaryUnconfigured(200, { + ...UNCONFIGURED_PAYLOAD, + unexpected: true, + }), + false, + ); + assert.equal( + isHudSummaryUnconfigured(200, { + code: HUD_SUMMARY_UNCONFIGURED_CODE, + }), + false, + ); }); test('does not hide real provider and HTTP failures', () => { - assert.equal(isHudSummaryUnconfigured(502, { - code: HUD_SUMMARY_UNCONFIGURED_CODE, - }), false); + assert.equal( + isHudSummaryUnconfigured(502, { + code: HUD_SUMMARY_UNCONFIGURED_CODE, + }), + false, + ); assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false); - assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false); + assert.equal( + isHudSummaryUnconfigured(200, { error: 'provider failed' }), + false, + ); }); test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => { @@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is for (let attempt = 0; attempt < 2; attempt += 1) { const response = await invokeRoute(hud, { method: 'POST' }); assert.equal(response.statusCode, 200); - assert.equal(response.headers['content-type'], 'application/json; charset=utf-8'); + assert.equal( + response.headers['content-type'], + 'application/json; charset=utf-8', + ); assert.equal(response.headers['cache-control'], 'no-store'); assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD); } } finally { if (previousKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = previousKey; - if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; + if (previousLimit === undefined) + delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN; else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit; } }); @@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', () id: 'flights', name: 'Live Flights', enabled: true, - stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' }, + stats: { + stale: true, + count: 12, + lastUpdate: now - 240_000, + source: 'OpenSky Network', + }, }, { id: 'earthquakes', @@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', () assert.deepEqual(context.enabledLayerLabels, ['Live Flights']); assert.equal(context.enabledLayers[0].feedState, 'stale'); assert.equal(context.feedProvenance.overall, 'stale'); - assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS'); - assert.equal(hudTelemetryProvenanceTag([{ - id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now }, - }], { now }), null); + assert.deepEqual(hudTelemetryProvenance(layers, { now }), { + state: 'stale', + names: ['Live Flights'], + }); + assert.equal( + hudTelemetryProvenance( + [ + { + id: 'flights', + name: 'Live Flights', + enabled: true, + stats: { count: 4, lastUpdate: now }, + }, + ], + { now }, + ), + null, + ); }); test('HUD summary instructions require non-nominal feedState in the five words', () => { - assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/); - assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/); + assert.match( + HUD_SUMMARY_INSTRUCTIONS, + /five words MUST include that feedState token/, + ); + assert.match( + HUD_SUMMARY_INSTRUCTIONS, + /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/, + ); assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/); }); test('the HUD proxy uses the shared provenance instructions', () => { - const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8'); + const local = readFileSync( + new URL('../server/providers/openai/hud-summary.js', import.meta.url), + 'utf8', + ); assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/); assert.doesNotMatch(local, /enabled-layer text labels/); }); test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => { - const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js'); - assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false); - assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true); - assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true); + const { hudSummaryMatchesProvenance } = + await import('./hudSummaryResponse.js'); + assert.equal( + hudSummaryMatchesProvenance('Austin flights operating normally today', { + overall: 'stale', + }), + false, + ); + assert.equal( + hudSummaryMatchesProvenance('Austin stale flights over downtown', { + overall: 'stale', + }), + true, + ); + assert.equal( + hudSummaryMatchesProvenance('Austin flights operating normally today', { + overall: 'nominal', + }), + true, + ); }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d0f1d9401..4245a62e3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1168,7 +1168,7 @@ "other": "{count} ciclones activos" }, "Position as of {time}": "Posición a fecha de {time}", - "Maximum sustained wind: {wind} · Pressure: {pressure}": "Viento máximo sostenido: {wind} · Presión: {pressure}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Viento máximo sostenido: {wind} · Presión: {pressure}", "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Aviso {number} · emitido {time}", "No active NHC/CPHC systems": "Sin sistemas activos del NHC/CPHC", "Advisories unavailable": "Avisos no disponibles", @@ -1250,7 +1250,7 @@ "History": "Historial", "Observation unavailable": "Observación no disponible", "Waiting for observation": "Esperando observación", - "{detail} · synced": "{detail} · sincronizado", + "{detail} · synced": "{detail} · sincr.", "{detail} · nearest": "{detail} · más cercano", "RADAR REFLECTIVITY · dBZ": "REFLECTIVIDAD DE RADAR · dBZ", "LIGHTNING DENSITY · 15 min accumulation": "DENSIDAD DE RAYOS · acumulación de 15 min", @@ -1396,7 +1396,6 @@ }, "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Archivo: {path}. {attribution} · {license}", "Unavailable layers: {layers}": "Capas no disponibles: {layers}", - "This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Esta escena usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.", "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} bytes empaquetados verificados. Los archivos permanecen en memoria durante esta sesión. Vuelve a importar el paquete después de recargar la app.", "Review scene import": "Revisar la importación de escena", "Nothing is loaded or changed until you apply this file.": "No se carga ni se cambia nada hasta que apliques este archivo.", @@ -1920,7 +1919,6 @@ "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", @@ -2011,5 +2009,19 @@ "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}" + "View is {size} wide · limit {limit}": "La vista mide {size} · límite {limit}", + "{state}: {layers}": "{state}: {layers}", + "{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}", + "Frame unavailable; previous observation retained": "Fotograma no disponible; se mantiene la observación anterior", + "Weather imagery unavailable": "Imágenes meteorológicas no disponibles", + "Weather source unavailable; previous observation retained": "Fuente meteorológica no disponible; se mantiene la observación anterior", + "Weather unavailable": "Clima no disponible", + "Weather tiles unavailable · previous frame retained": "Teselas meteorológicas no disponibles · se mantiene el fotograma anterior", + "Some weather tiles unavailable": "Algunas teselas meteorológicas no disponibles", + "Hidden by this map source · choose a globe map": "Oculto con esta fuente del mapa · elige un mapa de globo", + "Wind source unavailable": "Fuente de viento no disponible", + "Globe imagery unavailable": "Imágenes del globo no disponibles", + "Globe field image unavailable": "Imagen del campo en el globo no disponible", + "status chip|UNAVAILABLE": "NO DISP.", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5ffb0d905..6305ca15d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -76,7 +76,7 @@ "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; отдельный ключ — по желанию", + "Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию", "Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth", "Live ships, worldwide": "Суда в реальном времени по всему миру", "Live active-fire detections": "Обнаружение активных пожаров в реальном времени", @@ -899,7 +899,7 @@ "Draw": "Рисование", "Shape": "Фигура", "Shape to draw": "Фигура для рисования", - "Area": "Область", + "Area": "Зона", "Line": "Линия", "Pin": "Метка", "Label (optional)": "Подпись (необязательно)", @@ -1172,11 +1172,11 @@ "Tropical depression": "Тропическая депрессия", "Subtropical storm": "Субтропический шторм", "Subtropical depression": "Субтропическая депрессия", - "Track and cone match this advisory": "Трек и конус соответствуют этому бюллетеню", - "Track/cone awaiting advisory {number}": "Трек/конус ожидают бюллетень {number}", + "Track and cone match this advisory": "Трек и конус соответствуют этой сводке", + "Track/cone awaiting advisory {number}": "Трек и конус появятся со сводкой № {number}", "Track/cone unavailable": "Трек/конус недоступны", - "Cached advisory · stale source": "Бюллетень из кэша · источник устарел", - "Loading advisories…": "Загрузка бюллетеней…", + "Cached advisory · stale source": "Сводка из кэша · источник устарел", + "Loading advisories…": "Загрузка сводок…", "{count} active storms": { "one": "{count} активный шторм", "few": "{count} активных шторма", @@ -1184,19 +1184,19 @@ "other": "{count} активного шторма" }, "Position as of {time}": "Положение на {time}", - "Maximum sustained wind: {wind} · Pressure: {pressure}": "Макс. устойчивый ветер: {wind} · Давление: {pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Бюллетень {number} · выпущен {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Макс. устойчивый ветер: {wind} · Давление: {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Сводка № {number} · выпущена {time}", "No active NHC/CPHC systems": "Нет активных систем NHC/CPHC", - "Advisories unavailable": "Бюллетени недоступны", + "Advisories unavailable": "Сводки недоступны", "Cyclones · NHC / CPHC": "Циклоны · NHC / CPHC", "Atlantic · E/C Pacific": "Атлантика · вост./центр. Пацифика", "{storms} · {name} selected": "{storms} · выбран {name}", - "Official advisory ↗": "Официальный бюллетень ↗", - "Active NHC and CPHC cyclone advisories": "Активные бюллетени NHC и CPHC о циклонах", + "Official advisory ↗": "Официальная сводка ↗", + "Active NHC and CPHC cyclone advisories": "Действующие сводки NHC и CPHC о циклонах", "Wind unavailable": "Ветер: нет данных", - "Advisory center / forecast track": "Центр по бюллетеню / прогнозный трек", + "Advisory center / forecast track": "Центр циклона по сводке / прогнозный трек", "Center-track uncertainty cone": "Конус неопределённости трека центра", - "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "Выберите шторм на карте или в списке, чтобы выделить его и переместить к нему камеру. Щёлкните по пустому месту карты, чтобы снять выделение. Контекст бюллетеней NOAA NHC/CPHC. Конус описывает неопределённость прогнозного трека центра, а не размер шторма или всю зону опасности. Подписи прогнозных точек — заблаговременность источника в часах, а не время, рассчитанное от выпуска бюллетеня. Геометрия следует поверхности; высота не отражает высоту погодных явлений. Сверяйтесь с официальным бюллетенем.", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "Выберите шторм на карте или в списке, чтобы выделить его и переместить к нему камеру. Щёлкните по пустому месту карты, чтобы снять выделение. Контекст сводок NOAA NHC/CPHC. Конус описывает неопределённость прогнозного трека центра, а не размер шторма или всю зону опасности. Подписи прогнозных точек — заблаговременность источника в часах, а не время, рассчитанное от выпуска сводки. Геометрия следует поверхности; высота не отражает высоту погодных явлений. Сверяйтесь с официальной сводкой.", "Directions": "Маршруты", "{mode} — reroute for {modeLower}": "{mode} — перестроить маршрут: {modeLower}", "CLICK MAP": "ЩЁЛКНИТЕ ПО КАРТЕ", @@ -1266,7 +1266,7 @@ "History": "История", "Observation unavailable": "Наблюдение недоступно", "Waiting for observation": "Ожидание наблюдения", - "{detail} · synced": "{detail} · синхронизировано", + "{detail} · synced": "{detail} · синхр.", "{detail} · nearest": "{detail} · ближайший", "RADAR REFLECTIVITY · dBZ": "ОТРАЖАЕМОСТЬ РАДАРА · dBZ", "LIGHTNING DENSITY · 15 min accumulation": "ПЛОТНОСТЬ МОЛНИЙ · накопление за 15 мин", @@ -1318,7 +1318,7 @@ "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": "СНИМОК", - "OPACITY": "НЕПРОЗРАЧНОСТЬ", + "OPACITY": "НЕПРОЗР.", "Forecast · does not follow history": "Прогноз · не следует за историей", "Wind motion": "Движение ветра", "Wind speed": "Скорость ветра", @@ -1417,7 +1417,6 @@ }, "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Файл: {path}. {attribution} · {license}", "Unavailable layers: {layers}": "Недоступные слои: {layers}", - "This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Эта сцена использует зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют.", "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "Проверено байт в архиве: {bytes}. Файлы хранятся в памяти только в этом сеансе. После перезагрузки приложения импортируйте архив снова.", "Review scene import": "Проверка импорта сцены", "Nothing is loaded or changed until you apply this file.": "Ничего не загружается и не меняется, пока вы не примените этот файл.", @@ -1835,7 +1834,7 @@ "ALPR Cameras": "Камеры ALPR", "Military aircraft": "Военные самолёты", "Vessels": "Суда", - "CCTV + Street View fallback": "CCTV + резервный Street View", + "CCTV + Street View fallback": "CCTV + резерв: Просмотр улиц", "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + Google Maps Places (опц.)", "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · ПРОГНОЗ", "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · НАБЛЮДЕНИЕ", @@ -1951,7 +1950,6 @@ "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} · ЗАПРОС ПО МЕСТУ · СВЕЖЕЕ", @@ -1981,7 +1979,7 @@ "Snapshot feed connected": "Поток снимков подключён", "Media fetch failed": "Не удалось получить медиа", "Upstream snapshot active": "Снимок с источника активен", - "Fallback Street View frame": "Резервный кадр Street View", + "Fallback Street View frame": "Резервный кадр из Просмотра улиц", "Upstream unavailable": "Источник недоступен", "No source configured": "Источник не настроен", "Upstream HTTP {status}": "Источник: HTTP {status}", @@ -2043,5 +2041,19 @@ "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}" + "View is {size} wide · limit {limit}": "Вид шириной {size} · предел {limit}", + "{state}: {layers}": "{state}: {layers}", + "{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}", + "Frame unavailable; previous observation retained": "Кадр недоступен; показано предыдущее наблюдение", + "Weather imagery unavailable": "Погодные снимки недоступны", + "Weather source unavailable; previous observation retained": "Источник погоды недоступен; показано предыдущее наблюдение", + "Weather unavailable": "Погода недоступна", + "Weather tiles unavailable · previous frame retained": "Погодные тайлы недоступны · показан предыдущий кадр", + "Some weather tiles unavailable": "Часть погодных тайлов недоступна", + "Hidden by this map source · choose a globe map": "Скрыто этим источником карты · выберите глобус", + "Wind source unavailable": "Источник ветра недоступен", + "Globe imagery unavailable": "Снимки глобуса недоступны", + "Globe field image unavailable": "Изображение поля на глобусе недоступно", + "status chip|UNAVAILABLE": "НЕДОСТ.", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют." } diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 1b775249b..fb21a464b 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -17,10 +17,11 @@ import { import { t, tn, msg } from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; -// "10-12 15:00 UTC" never breaks across lines. +// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word +// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen). const utc = (value) => value - ? `${value.slice(5, 16).replace('T', '\u00a0')}\u00a0UTC` + ? `${value.slice(5, 16).replace('-', '-\u2060').replace('T', '\u00a0')}\u00a0UTC` : t('Unavailable'); const COVERAGE = msg( 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.', diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index d0db87d05..3a7fbdfc7 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\u00a003:00\u00a0UTC/); + // No line break inside the date: a word joiner holds the hyphen. + assert.match(controls.info, /issued 09-\u206016\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/); diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 099a2c3ff..5b2b5ce39 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -27,7 +27,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm'); export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), - sensor: (sensor) => t(sensor), + // "Sentinel-2" never breaks at its hyphen: a word joiner follows it + // (JetBrains Mono has no U+2011 non-breaking hyphen). + sensor: (sensor) => + t(sensor).replace(/(?<=[\p{L}\p{N}])-(?=[\p{L}\p{N}])/gu, '-\u2060'), metres: displayMetres, overview: () => t('overview'), cloud: (cloud) => { diff --git a/src/layers/weather/imageryHost.js b/src/layers/weather/imageryHost.js index f2fbb8682..29d171411 100644 --- a/src/layers/weather/imageryHost.js +++ b/src/layers/weather/imageryHost.js @@ -1,3 +1,5 @@ +import { msg } from '../../i18n/index.js'; + /** Resolve the visible surface that can own weather imagery without scene mutation. */ export function resolveImageryHost({ viewer, tileset }) { if (viewer?.scene?.globe?.show === true) @@ -7,7 +9,9 @@ export function resolveImageryHost({ viewer, tileset }) { return { collection: null, kind: 'none' }; } -export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map'; +export const NO_IMAGERY_HOST = msg( + 'Hidden by this map source · choose a globe map', +); /** Report imagery suspension without changing or detaching the resolved host. */ export function imageryHostStatus(host) { diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 5212a1dd7..820e6effa 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -7,7 +7,7 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, } from './clock.js'; -import { t } from '../../i18n/index.js'; +import { t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; const STOPS = [ @@ -229,12 +229,14 @@ export function createWeatherLayer({ rendering.setHidden?.(false); void warmNext(time); } - error = ok ? null : 'Frame unavailable; previous observation retained'; + error = ok + ? null + : msg('Frame unavailable; previous observation retained'); if (!ok) stop(); return ok; } catch { if (owner === generation && !controller.signal.aborted) { - error = 'Weather imagery unavailable'; + error = msg('Weather imagery unavailable'); stop(); } return false; @@ -340,7 +342,9 @@ export function createWeatherLayer({ if (!enabled || controller.signal.aborted || request !== controller) return false; if (snapshot.unavailable) { - error = 'Weather source unavailable; previous observation retained'; + error = msg( + 'Weather source unavailable; previous observation retained', + ); stop(); return true; } @@ -370,7 +374,7 @@ export function createWeatherLayer({ return !controller.signal.aborted && request === controller && enabled; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; - error = cause?.message || 'Weather unavailable'; + error = cause?.message || msg('Weather unavailable'); stop(); return true; } finally { @@ -524,6 +528,10 @@ export function createWeatherLayer({ }) : t('{minutes}m ago', { minutes: age }); const diagnostic = rendering?.getDiagnostics(); + // Errors are stored in English (stats, diagnostics); shown translated. + const problem = error || diagnostic?.error; + const shownProblem = problem ? t(problem) : null; + const shownHost = hostStatus ? t(hostStatus) : null; const b = manifest?.bounds; const canvas = viewer?.scene?.canvas; const point = @@ -618,9 +626,8 @@ export function createWeatherLayer({ detail, status: missing || - hostStatus || - error || - diagnostic?.error || + shownHost || + shownProblem || (observationDelayed() ? t('Source observations delayed') : manifest?.stale @@ -701,8 +708,8 @@ export function createWeatherLayer({ })) : [], info: hostHidden - ? hostStatus - : `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`, + ? shownHost + : `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`, infoTitle: lightning ? t( 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.', diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 8d195f302..cd9786123 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js'; import { imageryHostStatus } from './imageryHost.js'; import { createRasterTileProvider } from './rasterTiles.js'; import { createWeatherShell } from './shellRendering.js'; +import { msg } from '../../i18n/index.js'; import { readResponseBytesCapped } from '../../sources/httpBody.js'; import { acquireInfraredMosaic, @@ -294,7 +295,9 @@ function createGlobeRendering({ ); } } else { - lastError = 'Weather tiles unavailable · previous frame retained'; + lastError = msg( + 'Weather tiles unavailable · previous frame retained', + ); remove(frame); } frame.resolve(ok); @@ -413,7 +416,7 @@ function createGlobeRendering({ return; } frame.failed = true; - lastError = 'Some weather tiles unavailable'; + lastError = msg('Some weather tiles unavailable'); onChange(); }); // A shown, transparent layer lets Cesium request staging tiles while the last diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index cc8b32035..9def82c43 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -1,6 +1,7 @@ import { acquireWeatherImage } from './infraredImage.js'; import { imageryHostStatus } from './imageryHost.js'; import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js'; +import { msg } from '../../i18n/index.js'; /** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */ export const WEATHER_SHELL_HEIGHTS = Object.freeze({ @@ -630,7 +631,7 @@ export function createWeatherShell({ if (incoming !== frame) return; incoming = null; close(frame); - lastError = 'Weather tiles unavailable · previous frame retained'; + lastError = msg('Weather tiles unavailable · previous frame retained'); frame.resolve(false); scene.requestRender(); onChange(); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index d7bb01902..885c811f0 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -11,7 +11,7 @@ import { createWindInspectionMarker, WIND_UNITS, } from './inspection.js'; -import { t } from '../../i18n/index.js'; +import { t, msg } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; /** Format a forecast timestamp explicitly in UTC. */ @@ -174,7 +174,7 @@ export function createWindLayer({ return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; - error = cause?.message || 'Wind source unavailable'; + error = cause?.message || msg('Wind source unavailable'); return true; } finally { signal?.removeEventListener('abort', abort); @@ -260,8 +260,11 @@ export function createWindLayer({ requestedScalar() !== 'none' && manifest && (!manifest.scalar || manifest.scalar.kind !== overlay); + // Stored in English for diagnostics; shown translated. const imageryError = - enabled && !loading ? diagnostic?.imageryError : null; + enabled && !loading && diagnostic?.imageryError + ? t(diagnostic.imageryError) + : null; const label = { none: t('Wind motion'), speed: t('Wind speed'), @@ -303,7 +306,7 @@ export function createWindLayer({ ? t('Loading forecast') : preparing ? t('Preparing flow') - : error || + : (error && t(error)) || manifest?.reason || imageryError || (scalarMissing ? t('Selected field unavailable') : null) || @@ -384,7 +387,7 @@ export function createWindLayer({ model: model === 'ifs' ? 'ECMWF IFS' : 'GFS', field: label, 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 })}` : ''}`, + })}${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${t(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/rendering.js b/src/layers/wind/rendering.js index e9bce5780..bb891ae44 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -5,6 +5,7 @@ import { import { createWindRelief } from './relief.js'; import { orderWeatherImagery } from '../weather/imageryOrder.js'; import { NO_IMAGERY_HOST } from '../weather/imageryHost.js'; +import { msg } from '../../i18n/index.js'; import { createWindGpuRendering } from './gpuRendering.js'; import { advectParticle, sampleWind } from './model.js'; import { @@ -344,7 +345,7 @@ export function createWindRendering({ ? !cesium.Primitive : !collection || !cesium.SingleTileImageryProvider ) { - imageryError = 'Globe imagery unavailable'; + imageryError = msg('Globe imagery unavailable'); return; } try { @@ -384,12 +385,12 @@ export function createWindRendering({ imagery.alpha = overlay === 'temperature' ? 1 : 0.85; if (gpuActive) updateImageryFade(viewer.scene.camera, true); imageryErrorRemove = provider.errorEvent?.addEventListener(() => { - imageryError = 'Globe field image unavailable'; + imageryError = msg('Globe field image unavailable'); }); viewer.scene?.requestRender?.(); } catch { removeImagery(); - imageryError = 'Globe field image unavailable'; + imageryError = msg('Globe field image unavailable'); } } diff --git a/src/scenes/sharing.js b/src/scenes/sharing.js index dc3460295..183040651 100644 --- a/src/scenes/sharing.js +++ b/src/scenes/sharing.js @@ -135,7 +135,7 @@ export function createSceneSharing(director) { if (report.externalContent) dialog.text( t( - 'This scene uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.', + 'Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.', ), ); if (report.bundledBytes) diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index 974b32889..8be9b1ff9 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -1,5 +1,5 @@ /** Regional briefing requests, pages and rotation for the Cockpit controller. */ -import { t, msg } from '../i18n/index.js'; +import { formatNumber, t, msg } from '../i18n/index.js'; import { COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_CYCLE_OFF_HELP, @@ -296,7 +296,11 @@ export function renderRegionalBrief(payload, info) { this.localPrecipitation.textContent = Number.isFinite( weather?.precipitationMm, ) - ? weather.precipitationMm.toFixed(1) + ? formatNumber(weather.precipitationMm, { + useGrouping: false, + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) : '—'; } if (this.signalStream) diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 9d177a078..57db349ee 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -8,6 +8,7 @@ import { relativeBearing, resolveCockpitContextReadout, } from '../cockpitMath.js'; +import { formatCockpitDistance } from './cockpitPresentation.js'; /** The Context scope line in the active language (see cockpitMath.js). */ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ @@ -117,12 +118,7 @@ export function updateContext(info, heading) { if (this.contextDistance) { const distanceM = closest?.distanceM; this.contextDistance.textContent = Number.isFinite(distanceM) - ? t('{value} KM', { - value: - distanceM < 10000 - ? (distanceM / 1000).toFixed(1) - : Math.round(distanceM / 1000), - }) + ? formatCockpitDistance(distanceM) : '—'; this.contextDistance.setAttribute( 'aria-label', diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index eb48ab817..4a73b8868 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -1,5 +1,5 @@ /** Instrument values, routes and vision controls for the Cockpit controller. */ -import { t } from '../i18n/index.js'; +import { formatNumber, t } from '../i18n/index.js'; import { COCKPIT_VISION_MODES, normalizeCockpitVisionMode, @@ -69,7 +69,7 @@ export function updateHud( setCockpitRollingValue( this.altitude, displayedAltitudeFt !== null - ? displayedAltitudeFt.toLocaleString('en-US') + ? formatNumber(displayedAltitudeFt) : '-----', displayedAltitudeFt, { immediate: forceContext }, diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 9d21934a3..256ea3c89 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,5 +1,6 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ import { formatNumber, t, msg } from '../i18n/index.js'; +import { formatQuantity } from './unitLabels.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -126,6 +127,18 @@ export function formatCockpitCoordinate(value, positive, negative) { return `${amount}°${compassPointLabel(value >= 0 ? positive : negative)}`; } +/** + * A contact distance such as `4.2 KM` / `44 KM` in the active language + * (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond. + */ +export function formatCockpitDistance(distanceM) { + const digits = distanceM < 10000 ? 1 : 0; + return formatQuantity(distanceM / 1000, 'km', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).toUpperCase(); +} + export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); @@ -133,6 +146,16 @@ export function formatCockpitWindDirection(value) { return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`; } +// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a +// narrow cell; a full digit cell would open a gap in the middle of a number. +const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/; + +function rollTokenClass(character) { + return ROLL_SEPARATOR.test(character) + ? 'cockpit-roll-token is-separator' + : 'cockpit-roll-token'; +} + export function setCockpitRollingValue( element, text, @@ -169,7 +192,7 @@ export function setCockpitRollingValue( element.replaceChildren( ...Array.from(nextText, (character) => { const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; + token.className = rollTokenClass(character); token.setAttribute('aria-hidden', 'true'); token.textContent = character; return token; @@ -198,7 +221,7 @@ export function setCockpitRollingValue( const previousCharacter = from[index]; const nextCharacter = to[index]; const token = document.createElement('span'); - token.className = 'cockpit-roll-token'; + token.className = rollTokenClass(nextCharacter); token.setAttribute('aria-hidden', 'true'); if ( diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 625e140ba..cbac4cd03 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -1,5 +1,6 @@ /** Reconcile actionable signal rows without disturbing native keyboard focus. */ import { t, tn } from '../i18n/index.js'; +import { formatCockpitDistance } from './cockpitPresentation.js'; export function renderCockpitSignals() { if (!this.signalList) return; @@ -157,7 +158,7 @@ export function updateCockpitSignals(snapshot, unknownCount) { ? t('MILITARY FLIGHT') : t('COMMERCIAL FLIGHT'), distance: Number.isFinite(item.distanceM) - ? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` + ? formatCockpitDistance(item.distanceM) : t('DISTANCE UNKNOWN'), }), target: { layerId: cohort.id, id: String(id) }, diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 4c75e2d73..f32cb2b73 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -206,6 +206,64 @@ export function layerKeyRequirementTooltip(layer = {}) { : ''; } +const ROW_TEXT_SEPARATOR = ' · '; +// A short Latin code joined by hyphens. Longer runs (a URL, a token in an +// error) keep their break opportunities so they cannot overflow the rail. +const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g; +const HYPHENATED_CODE_MAX = 16; + +/** A span for one run of row text; `keep` stops the browser breaking inside it. */ +function rowTextSpan(text, keep) { + const span = document.createElement('span'); + if (keep) span.className = 'data-keep'; + span.textContent = text; + return span; +} + +/** + * Set a row's name, count or meta line so that a narrow column wraps it well + * in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never + * splits at its hyphen, and a " · " separator stays at the end of the line it + * closes, so no line starts with "·". The element's textContent is still the + * plain string; text with neither feature is set directly. + * + * @param {HTMLElement} element + * @param {string} text + */ +export function setRowText(element, text) { + const value = String(text ?? ''); + // Refreshes run every second; leave unchanged text (and its spans) alone. + if (element.textContent === value) return; + const codes = [...value.matchAll(HYPHENATED_CODE)].filter( + (match) => match[0].length <= HYPHENATED_CODE_MAX, + ); + if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) { + element.textContent = value; + return; + } + const spans = []; + const pushPlain = (from, to) => { + if (to <= from) return; + const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR); + pieces.forEach((piece, index) => { + // The space before the dot belongs to a no-wrap span, so the only break + // opportunity around a separator is the space after it. + if (index > 0) spans.push(rowTextSpan(' ·', true)); + const textPiece = index > 0 ? ` ${piece}` : piece; + if (textPiece) spans.push(rowTextSpan(textPiece, false)); + }); + }; + let cursor = 0; + for (const match of codes) { + pushPlain(cursor, match.index); + spans.push(rowTextSpan(match[0], true)); + cursor = match.index + match[0].length; + } + pushPlain(cursor, value.length); + element.textContent = ''; + for (const span of spans) element.appendChild(span); +} + /** Layer row presentation over supplied state and actions; no layer imports. */ export class LayerPanel { constructor({ @@ -331,7 +389,7 @@ export class LayerPanel { icon.textContent = layer.icon; const name = document.createElement('span'); name.className = 'data-name'; - name.textContent = panelLabel(layer); + setRowText(name, panelLabel(layer)); left.appendChild(icon); left.appendChild(name); @@ -340,7 +398,7 @@ export class LayerPanel { const count = document.createElement('span'); count.className = 'data-count'; - count.textContent = this._layerCountText(layer.stats); + this._syncCount(row, count, layer.stats); const toggle = document.createElement('button'); toggle.type = 'button'; @@ -379,7 +437,7 @@ export class LayerPanel { const bottomRow = document.createElement('div'); bottomRow.className = 'data-toggle-meta'; - bottomRow.textContent = this._buildMetaText(layer); + setRowText(bottomRow, this._buildMetaText(layer)); row.appendChild(topRow); row.appendChild(bottomRow); @@ -482,6 +540,21 @@ export class LayerPanel { ); } + /** + * Write a row's count. A count that is a phrase carrying its own number + * ("24 nearby · 2 on screen") moves to its own line under the name: beside + * the toggle it squeezes the name to a word per line in any language. The + * "—" placeholder is marked so it can give way to a wide status chip. + */ + _syncCount(row, count, stats) { + const text = this._layerCountText(stats); + setRowText(count, text); + const phrase = + typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel); + row.classList.toggle('data-toggle-row--count-line', phrase); + count.classList.toggle('data-count-empty', text === '—'); + } + /** Qualify a loaded count when it does not mean items currently on screen. */ _layerCountText(stats) { if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) @@ -605,12 +678,12 @@ export class LayerPanel { const count = row.querySelector('.data-count'); if (count) { - count.textContent = this._layerCountText(layer.stats); + this._syncCount(row, count, layer.stats); } const meta = row.querySelector('.data-toggle-meta'); if (meta) { - meta.textContent = this._buildMetaText(layer); + setRowText(meta, this._buildMetaText(layer)); } this._syncRowControls( @@ -765,13 +838,21 @@ export class LayerPanel { String(transitioning || (unavailable && !layer.enabled)), ); button.setAttribute('aria-busy', String(transitioning)); - button.textContent = transitioning + const showsFeedState = + !transitioning && !uncertain && (layer.enabled || unavailable); + const stateText = transitioning ? lifecycleStateLabel(layer.lifecycleState) : uncertain ? t('UNCERTAIN') - : layer.enabled || unavailable + : showsFeedState ? t(FEED_STATE_LABEL_KEYS[feedState]) : t('OFF'); + // The chip may abbreviate where a language's word is wider than the + // status column; the meta line and the accessible name keep the full word. + button.textContent = + showsFeedState && feedState === 'unavailable' + ? t('UNAVAILABLE', { context: 'status chip' }) + : stateText; const keyGuidance = unavailable ? String(layer.stats.error || t('Data source unavailable')) : layerKeyRequirementTooltip(layer); @@ -781,7 +862,7 @@ export class LayerPanel { button.title = keyGuidance; const ariaLabel = t('{name}: {state}', { name: panelLabel(layer), - state: button.textContent, + state: stateText, }); button.setAttribute( 'aria-label', diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index c133f0733..aa4d30f28 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -260,3 +260,72 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async 'an active layer can always be switched off', ); }); + +test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => { + const { setRowText } = await import('./layerPanel.js'); + const previousDocument = globalThis.document; + const element = () => ({ + className: '', + textContent: '', + children: [], + appendChild(child) { + this.children.push(child); + return child; + }, + }); + globalThis.document = { createElement: element }; + try { + const text = (node) => + node.children.length + ? node.children.map((child) => child.textContent).join('') + : node.textContent; + const kept = (node) => + node.children + .filter((child) => child.className === 'data-keep') + .map((child) => child.textContent); + + const name = element(); + setRowText(name, 'Локальный ADS-B'); + assert.equal(text(name), 'Локальный ADS-B'); + assert.deepEqual(kept(name), ['ADS-B']); + + const meta = element(); + setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); + assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); + assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']); + assert.ok( + meta.children.every((child) => !child.textContent.startsWith('·')), + 'the space before each dot is held to the piece it closes', + ); + + const plain = element(); + setRowText(plain, 'Satellites'); + assert.equal(plain.textContent, 'Satellites'); + assert.equal(plain.children.length, 0); + + const long = element(); + setRowText(long, 'see https-proxy-endpoint-config-x'); + assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap'); + } finally { + globalThis.document = previousDocument; + } +}); + +test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const attrs = new Map(); + const button = { + classList: { toggle() {} }, + dataset: {}, + setAttribute: (key, value) => attrs.set(key, value), + }; + LayerPanel.prototype._syncToggleButton(button, { + id: 'local-firms', + name: 'FIRMS Active Fires', + enabled: false, + stats: { sourceUnavailable: true, error: 'KEY REQUIRED' }, + }); + assert.equal(button.textContent, 'UNAVAILABLE'); + assert.equal(button.dataset.feedState, 'unavailable'); + assert.match(attrs.get('aria-label'), /^FIRMS Active Fires: UNAVAILABLE\./); +}); diff --git a/src/ui/styles/bhote-koshi.css b/src/ui/styles/bhote-koshi.css index b2d774ce9..326cbe282 100644 --- a/src/ui/styles/bhote-koshi.css +++ b/src/ui/styles/bhote-koshi.css @@ -281,8 +281,10 @@ border-top: 3px solid #6be8ff; } -/* Up to two lines, so a translated title is not cut to a stub. */ +/* The title takes the full card width and up to two lines, so a translated + title keeps its place name; the link sits beside the status below it. */ .bhote-embedded-callout footer strong { + grid-column: 1 / -1; display: -webkit-box; overflow: hidden; font-size: 11px; @@ -292,9 +294,9 @@ -webkit-line-clamp: 2; } -/* The status takes the full width under the title and link. */ .bhote-embedded-callout footer small { - grid-column: 1 / -1; + grid-column: 1; + align-self: center; color: rgba(151, 211, 224, 0.7); font-size: 8px; line-height: 1.35; @@ -302,7 +304,7 @@ .bhote-embedded-callout footer a { grid-column: 2; - grid-row: 1; + grid-row: 2; align-self: center; white-space: nowrap; color: #6be8ff; @@ -450,7 +452,7 @@ align-content: center; column-gap: 9px; min-width: 0; - padding: 7px 10px; + padding: 5px 10px; border: 1px solid rgba(88, 196, 218, 0.18); background: rgba(6, 19, 27, 0.72); } @@ -462,20 +464,24 @@ line-height: 1.25; } +/* Up to two lines: a translated beat title keeps its subject. Two title lines + and one meta line still fit the 54px arrow buttons, so the controls stay put. */ .bhote-event-story-current strong { + display: -webkit-box; overflow: hidden; color: var(--text-primary); font-size: 11px; line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } /* Phase / time / sources run long in translation: allow a second line. */ .bhote-event-story-current small { display: -webkit-box; grid-column: 1 / -1; - margin-top: 4px; + margin-top: 3px; overflow: hidden; color: rgba(116, 216, 235, 0.7); -webkit-box-orient: vertical; diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 74a1de034..b858121ad 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -106,22 +106,26 @@ html[lang='es'] .cctv-controls { } /* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact - value chips replace the seven offset sliders. */ + value chips replace the seven offset sliders. Tight enough that the seven + chips take two rows in a long-label language too, which keeps SAVE / RESET + in view; chips grow to fill each row, and a value never wraps inside one. */ .cctv-cal-readout { display: flex; flex-wrap: wrap; - gap: 5px; + gap: 4px; } .cctv-cal-value { + flex: 1 1 auto; + white-space: nowrap; font-family: var(--font-mono); font-size: 9px; - letter-spacing: 0.7px; + letter-spacing: 0.3px; color: #91edff; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 7px; - padding: 4px 7px; + padding: 4px; cursor: text; transition: border-color 0.15s ease, diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index f6678f761..41c7a0d5f 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -569,6 +569,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) text-align: center; vertical-align: -0.08em; } +.cockpit-roll-token.is-separator { + min-width: 0.3em; +} .cockpit-roll-token.is-rolling { color: color-mix(in srgb, currentColor 92%, var(--cockpit-accent)); text-shadow: 0 0 10px diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 19e189747..464578073 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -977,6 +977,11 @@ #draw-mode-row { min-width: 184px; } +/* Equal thirds with room around the label, so a long shape name never runs + edge to edge in its segment. */ +#draw-mode-row .pp-mode-btn { + padding: 0 4px; +} #draw-label-row { flex-wrap: wrap; row-gap: 6px; diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index f6ab14f37..85ae6cace 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -663,6 +663,54 @@ text-wrap: balance; } +/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and + the space before a " · " separator (see setRowText in layerPanel.js). */ +.data-keep { + white-space: nowrap; +} + +/* A count phrase ("24 nearby · 2 on screen") takes its own line under the + name instead of squeezing it beside the toggle. */ +.data-toggle-row--count-line .data-toggle-top { + flex-wrap: wrap; + row-gap: 2px; +} + +.data-toggle-row--count-line .data-toggle-right { + display: contents; +} + +/* A zero basis, so a long name wraps beside the toggle instead of pushing the + toggle onto the count's line. */ +.data-toggle-row--count-line .data-toggle-left { + flex-basis: 0; +} + +.data-toggle-row--count-line .data-count { + order: 1; + flex: 1 0 100%; + padding-left: 26px; + font-size: 10px; + text-align: left; +} + +/* The "—" placeholder gives way to a wide status chip, so the name keeps the + room it needs. */ +.data-toggle-right:has( + .data-toggle-btn:is( + .transitioning, + .feed-loading, + .feed-degraded, + .feed-stale, + .feed-partial, + .feed-fallback, + .feed-unavailable + ) + ) + .data-count-empty { + display: none; +} + .data-toggle-btn { padding: 3px 10px; font-family: var(--font-mono); @@ -693,13 +741,15 @@ outline-offset: -3px; } +/* Status chips up to eight characters share one width; a longer word grows + its own chip rather than widening every chip and squeezing every name. */ .data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded, .data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial, .data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable { - min-width: 82px; + min-width: 72px; letter-spacing: 0.8px; } diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 8a431df14..d49e18cd3 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -147,9 +147,10 @@ .rail-card-line { white-space: pre-line; } +/* One line stays reserved so the card does not jump when a status appears; + a long (or translated) status wraps instead of being cut. */ .weather-card [data-line-id='status'] { - height: 1.45em; - overflow-y: auto; + min-height: 1.45em; color: var(--accent); } .rail-card-ramp {