fix(i18n): second screenshot review: remaining English, numbers and wraps

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 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-13 19:13:12 +04:00
1 parent 2b6ccad732
commit 54dce5b3bd
32 files changed
+1204 -346

No files matched your search

+37 -34
View File
@@ -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) | Л / П |
+206 -53
View File
@@ -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);
+22 -2
View File
@@ -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,
+408 -121
View File
@@ -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);
});
+34 -4
View File
@@ -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),
+7 -5
View File
@@ -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<object>} [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. */
+111 -39
View File
@@ -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,
);
});
+17 -5
View File
@@ -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."
}
+32 -20
View File
@@ -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.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные уведомления по-прежнему действуют."
}
+3 -2
View File
@@ -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.',
+2 -1
View File
@@ -565,7 +565,8 @@ test('advisory selection uses accessible row descriptors and shared camera hando
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16\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/);
+4 -1
View File
@@ -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) => {
+5 -1
View File
@@ -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) {
+17 -10
View File
@@ -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.',
+5 -2
View File
@@ -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
+2 -1
View File
@@ -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();
+8 -5
View File
@@ -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.',
),
+4 -3
View File
@@ -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');
}
}
+1 -1
View File
@@ -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)
+6 -2
View File
@@ -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)
+2 -6
View File
@@ -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',
+2 -2
View File
@@ -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 },
+25 -2
View File
@@ -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 (
+2 -1
View File
@@ -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) },
+89 -8
View File
@@ -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',
+69
View File
@@ -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\./);
});
+14 -8
View File
@@ -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;
+8 -4
View File
@@ -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,
+3
View File
@@ -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
+5
View File
@@ -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;
+51 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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 {