Merge origin/i18n-spanish: whole-interface translation meets KartaView

This branch moved twice at once: upstream merged KartaView (#1121, a
third keyless street-level provider sharing a new lineCoverage.js), and
the remote i18n-spanish gained the whole-interface translation work
(1993 catalog entries, translateKnown, data-i18n-runtime, the unmarked-
string detector in npm run i18n) built on the Street View merge.

Integration keeps the remote's mechanisms and re-adds KartaView on top:

- presentMeta: remote's translateHint for coverage hints, plus the
  KartaView count line as its own literal tn pair (Russian plurals need
  the noun inside the template; keyed off coverageLabel.noun with the
  sequences pair as the default).
- uiState: upstream's highlightsSequence legend gate (KartaView's lines
  do not highlight) with t('Selected').
- layer-panels.html: remote's data-i18n/data-i18n-runtime hooks plus the
  sl-since-note wiring KartaView's upload-date SINCE note needs.
- kartaview viewer: the photo-navigation aria-label and img alt go
  through t(), as the remote's unmarked-string detector requires.
- catalogs: remote's 1987-entry base (its refined keys supersede this
  branch's earlier Street View wording) plus the KartaView entries:
  coverage hints, the upload-date note, the streets count line (with
  Russian one/few/many/other forms) and the viewer strings.

npm run i18n: complete, no unmarked strings. npm test: 6520/6522 (the
one failure is the pre-existing environmental OPENAI_API_KEY leak in
devFreshDotenv). Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-14 17:45:57 +02:00
commit 32b11e27de
176 files changed
+9713 -2210

No files matched your search

+3 -1
View File
@@ -21,7 +21,9 @@
switch in the Visual Presets dock, then the browser language; English stays
the default and the fallback. `npm run i18n`, also run by `npm test`, fails on
missing, unused or broken translations. Built on the i18n foundation by
@meirm (#601), with the Russian catalog started from #312.
@meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is
not marked at all, so the whole interface, including surfaces added after
the original extraction, is translated (1,679 messages).
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
+5 -2
View File
@@ -3065,8 +3065,11 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
- **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution.
- **Gaps.** Strings added after #601's extraction (for example the Street Level
panel) are still raw English; there is no lint for unmarked UI text yet.
- **Unmarked text.** The same check lists UI text that is not marked at all
(markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`)
and fails on it. Text kept on purpose says why: `translate="no"`,
`data-i18n-runtime`, or `// i18n-ignore: <reason>`. `src/tools/` is skipped:
it writes for AI clients, not the page.
## Canonical Docs Order
+29 -9
View File
@@ -43,13 +43,13 @@ label.textContent = t(FEED_STATE_LABELS[state]);
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language.
- **Identical English means identical translation.** If two places need different words, make the English differ.
- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
## After you add or change text
```sh
npm run i18n # shows what each language now lacks or no longer uses
npm run i18n # what each language lacks or no longer uses, and unmarked UI text
npm run i18n -- --missing ru # the missing ru entries as JSON
npm run i18n -- --prune # drop entries whose English no longer exists
```
@@ -58,12 +58,32 @@ Changing the English changes the key, so the old translation goes unused and the
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock.
A translation that is exactly the English ("Bloom" → "Bloom") is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
## Text the check reports as unmarked
`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check:
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties.
Fix it by marking the text. When the text must stay as it is, say so where it lives:
| Case | How |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
## Where things live
| File | What it holds |
| ------------------------------ | ----------------------------------------------------------------------------- |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages and checks the catalogs |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
| File | What it holds |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
+1 -1
View File
@@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla
npm run i18n -- --prune # drop entries the code no longer uses
```
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, or when a plural entry lacks a form.
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation is still the English text. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`.
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
+6 -1
View File
@@ -39,7 +39,7 @@ The `many` form is the `other` text with «de» after the number: «{count} de c
| retry / retrying | reintento / reintentando |
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
| Provider Settings | Ajustes de proveedores |
| ON / OFF (state chips) | ACTIVADO / DESACTIVADO |
| ON / OFF (state chips) | ACTIVO / INACTIVO (short enough for the layer pill) |
| enable / disable | activar / desactivar |
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares |
@@ -61,10 +61,15 @@ The `many` form is the `other` text with «de» after the number: «{count} de c
| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura |
| feather / fade | difuminado / desvanecimiento |
| detection overlay | superposición de detección |
| pin (Draw tool) | marcador |
| Bloom / Glow (visual effect) | resplandor |
| Cyber (HUD layout, effect) | Ciber |
| heading, course / altitude / ground speed | rumbo / altitud / velocidad respecto al suelo (short: VEL. SUELO) |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
| PREV / NEXT | ANT. / SIG. |
| Previous / Next (button text) | Anterior / Siguiente |
| L / R (relative bearing) | I / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTICIAS / LOCAL |
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO |
| click / click to … | haz clic / clic para … |
+39 -30
View File
@@ -21,36 +21,45 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
| `other` | fractions: 1.5, 2.25… | 1,5 камеры |
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}».
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}».
## 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) | Назад / Далее |
| 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, без перевода |
| WHOT/BHOT, Ironbow (thermal modes, palette) | без перевода |
| 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) | Л / П |
+318 -17
View File
@@ -1,11 +1,16 @@
// Keeps the locale catalogs in step with the code.
//
// npm run i18n check every catalog (also run by npm test)
// npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
//
// A translation identical to the English is reported too, unless it is
// listed for that locale in src/i18n/same-as-english.json (codes, product
// names, words spelled the same in both languages).
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
import { readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
@@ -13,10 +18,11 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
String.raw`\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
'g',
);
@@ -93,26 +99,19 @@ export function extractMessages() {
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
};
const sources = trackedFiles(
'src/*.js',
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
!/\.test\.mjs$|\/testSupport\//.test(file),
);
const sources = sourceFiles();
for (const file of sources) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
for (const match of source.matchAll(CALL)) {
const line = source.slice(0, match.index).split('\n').length;
const where = `${file}:${line}`;
if (match[2]) {
if (match[2][0] === '`' && match[2].includes('${')) continue;
add(unquote(match[2]), where);
if (match[1]) {
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
} else if (match[4]) {
if (match[4][0] === '`' && match[4].includes('${')) continue;
add(unquote(match[4]), where);
} else {
add(unquote(match[4]), where, { one: unquote(match[3]) });
add(unquote(match[6]), where, { one: unquote(match[5]) });
}
}
}
@@ -140,6 +139,276 @@ export function extractMessages() {
return messages;
}
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
const VOID_TAGS = new Set(
'area base br col embed hr img input link meta source track wbr'.split(' '),
);
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
/**
* User-visible markup text without data-i18n*. Skipped, with everything
* inside: translate="no" (text that stays as is, as in HTML),
* data-i18n-runtime (code sets this text through t()), and icons
* (aria-hidden, Material Symbols).
*/
export function unmarkedMarkup(file, html) {
const found = [];
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
c.replace(/[^\n]/g, ' '),
);
const TOKEN =
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
const stack = [];
const skipping = () => stack.at(-1)?.skip === true;
for (const match of source.matchAll(TOKEN)) {
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
if (text !== undefined) {
const top = stack.at(-1);
const value = normalize(decodeHtml(text));
if (!skipping() && hasWords(value) && !top?.marked)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: value,
});
continue;
}
const tag = rawTag.toLowerCase();
if (closing) {
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
if (at >= 0) stack.length = at;
continue;
}
const skip =
skipping() ||
SKIP_TAGS.has(tag) ||
/\stranslate="no"/.test(attrs) ||
/\sdata-i18n-runtime\b/.test(attrs) ||
/\saria-hidden="true"/.test(attrs) ||
/class="[^"]*material-symbols/.test(attrs);
if (!skip) {
for (const [, name, value] of attrs.matchAll(
/\s(title|aria-label|placeholder)="([^"]*)"/g,
)) {
if (
hasWords(value) &&
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
)
found.push({
where: `${file}:${lineAt(source, match.index)}`,
text: `${name}="${decodeHtml(value)}"`,
});
}
}
if (!VOID_TAGS.has(tag) && !selfClosing)
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
}
return found;
}
// Where a string becomes UI text. Each pattern ends where the value starts.
const JS_SINKS = [
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
/\b(?:showToast|toast|setHint|announce)\(\s*/g,
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
];
const MARKERS = /\b(?:t|tn|msg)\(/y;
/** The expression starting at `start`, up to its end at bracket depth 0. */
function expressionAt(source, start) {
let depth = 0;
let i = start;
for (; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
i = skipString(source, i);
continue;
}
if ('([{'.includes(c)) depth++;
else if (')]}'.includes(c)) {
if (depth === 0) break;
depth--;
} else if (depth === 0 && (c === ';' || c === ',')) break;
else if (depth === 0 && c === '\n') {
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
break;
}
}
return source.slice(start, i);
}
function skipString(source, i) {
const quote = source[i];
for (let j = i + 1; j < source.length; j++) {
if (source[j] === '\\') j++;
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
let depth = 1;
for (j += 2; j < source.length && depth; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}') depth--;
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
}
j--;
} else if (source[j] === quote) return j;
else if (quote !== '`' && source[j] === '\n') return j;
}
return source.length;
}
/** String literals that sit in value position: not inside any call or index,
* not compared, and not already passed to t/tn/msg. */
function valueLiterals(expression) {
const literals = [];
let callDepth = 0;
const stack = [];
for (let i = 0; i < expression.length; i++) {
const c = expression[i];
if (c === '(' || c === '[') {
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
stack.push(isCall);
if (isCall) callDepth++;
continue;
}
if (c === ')' || c === ']') {
if (stack.pop()) callDepth--;
continue;
}
MARKERS.lastIndex = i;
if (MARKERS.test(expression)) {
stack.push(true);
callDepth++;
i = MARKERS.lastIndex - 1;
continue;
}
if (c !== "'" && c !== '"' && c !== '`') continue;
const end = skipString(expression, i);
const literal = expression.slice(i, end + 1);
const before = expression.slice(0, i).trimEnd();
const after = expression.slice(end + 1).trimStart();
const compared =
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
/^(===|!==|==|!=|in\b)/.test(after);
if (callDepth === 0 && !compared) literals.push(literal);
i = end;
}
return literals;
}
/** The text a literal shows: static template parts, HTML tags removed. */
function visibleText(literal, html) {
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
if (html) {
const attrs = [
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
].map((m) => m[1]);
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
}
const text = normalize(body);
if (!hasWords(text)) return null;
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
return text;
}
/** Blank out comments, keeping strings and line numbers intact. */
function withoutComments(source) {
let out = '';
for (let i = 0; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
const end = skipString(source, i);
out += source.slice(i, end + 1);
i = end;
} else if (c === '/' && source[i + 1] === '/') {
const end = source.indexOf('\n', i);
const stop = end < 0 ? source.length : end;
out += ' '.repeat(stop - i);
i = stop - 1;
} else if (c === '/' && source[i + 1] === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end < 0 ? source.length : end + 2;
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop - 1;
} else out += c;
}
return out;
}
export function unmarkedScript(file, original) {
const found = [];
const lines = original.split('\n');
const source = withoutComments(original);
const ignored = (line) =>
/i18n-ignore/.test(lines[line - 1] ?? '') ||
/i18n-ignore/.test(lines[line - 2] ?? '');
for (const sink of JS_SINKS) {
sink.lastIndex = 0;
for (const match of source.matchAll(sink)) {
const start = match.index + match[0].length;
const line = lineAt(source, start);
if (ignored(line)) continue;
const html = /HTML/.test(match[0]);
for (const literal of valueLiterals(expressionAt(source, start))) {
const text = visibleText(literal, html);
if (text) found.push({ where: `${file}:${line}`, text });
}
}
}
return found;
}
/**
* UI text that is not marked for translation: markup without data-i18n* and
* string literals that reach the page without t(), tn() or msg().
* Opt out with translate="no" in markup or an `// i18n-ignore` comment on
* (or just above) the line in code.
* @returns {{where: string, text: string}[]}
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
found.push(
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
const seen = new Set();
return found.filter(({ where, text }) => {
const key = `${where}\0${text}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
function sourceFiles() {
return trackedFiles(
'src/*.js',
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
!/\.test\.mjs$|\/testSupport\//.test(file),
);
}
const placeholders = (text) =>
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
.map((m) => m[1])
@@ -160,11 +429,26 @@ export function shippedLocales() {
);
}
/** Messages a locale keeps exactly as in English, on purpose. */
export function readSameAsEnglish(locale) {
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
return new Set(lists[locale] || []);
}
/** The English a message shows: its text without the "context|" prefix. */
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
* @returns {{missing: string[], stale: string[], errors: string[]}}
*/
export function checkCatalog(locale, catalog, messages) {
export function checkCatalog(
locale,
catalog,
messages,
sameAsEnglish = readSameAsEnglish(locale),
) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const missing = [];
@@ -192,6 +476,14 @@ export function checkCatalog(locale, catalog, messages) {
errors.push(`"${text}" must be a string`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
/\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) &&
!sameAsEnglish.has(text)
) {
errors.push(
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`,
);
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
@@ -249,6 +541,15 @@ function main(args) {
console.log(`${locale}: ${messages.size} messages, complete`);
}
}
const unmarked = findUnmarked();
if (unmarked.length) {
failed = true;
console.log(
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
);
for (const { where, text } of unmarked)
console.log(` ${where} ${JSON.stringify(text)}`);
}
return failed ? 1 : 0;
}
+57 -16
View File
@@ -455,7 +455,9 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js"
],
"external": []
},
@@ -502,7 +504,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": []
},
@@ -556,7 +559,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -569,7 +573,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": []
},
@@ -712,7 +717,9 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js"
],
"external": ["cesium"]
},
@@ -822,7 +829,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -880,7 +888,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -1003,7 +1012,13 @@
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js",
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js"
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js"
],
"external": ["cesium"]
},
@@ -1130,7 +1145,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/layers/installations/classLabel.js"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
},
@@ -1161,7 +1177,8 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -1356,7 +1373,11 @@
"src/sources/httpBody.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/layers/alpr/floors.js"
"src/layers/alpr/floors.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
],
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
},
@@ -2341,7 +2362,11 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js",
"src/layers/installations/classLabel.js"
],
"external": [
"hls.js",
@@ -2880,7 +2905,10 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js"
],
"external": [
"hls.js",
@@ -3120,7 +3148,12 @@
"modules": [
"src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/data/routeSteps.js"
"src/data/routeSteps.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
},
@@ -3398,7 +3431,11 @@
"src/sources/overpassFeaturesRecords.js",
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/capability.js"
"src/sources/capability.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
@@ -3480,7 +3517,11 @@
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js"
"src/layers/perimeters/inciweb.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
],
"external": ["cesium"]
},
+3 -2
View File
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 500));
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
if (status && status !== 'CONNECTING') break;
// The label is translated; the machine state is on the control.
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
if (status && status !== 'connecting') break;
}
let diag = null;
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
+9
View File
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'ILM / Lucasfilm',
color: 'cyan',
},
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'next to the Marina',
},
],
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'Palace of Fine Arts',
color: 'amber',
},
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'next to the Marina',
color: 'cyan',
},
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'ILM / Lucasfilm',
color: 'red',
},
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
{
type: 'route',
color: 'amber',
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
+14 -3
View File
@@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) {
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint(t('That point is off the globe — click on the world.'));
@@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) {
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
setHint(
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
);
return null;
}
};
@@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
setHint(
t('{owner} is using the pointer — close it first.', {
owner: pointerOwner(),
}),
);
return active;
}
}
+11 -5
View File
@@ -4,6 +4,12 @@ import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
return t('{label} · Detailed outline unavailable', { label: label || '' });
}
/**
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.path);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
});
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
}
+3
View File
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
@@ -616,6 +617,7 @@ export function panelRuntime(config) {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
@@ -640,6 +642,7 @@ export function panelRuntime(config) {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
// i18n-ignore: product name sent to the MCP host
title: "God's Eye View",
version: '1.0.0',
},
+647 -183
View File
File diff suppressed because it is too large. Load diff
+26 -5
View File
@@ -83,11 +83,32 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/** Format the cockpit Context scope without overstating installation coverage. */
/**
* The English Context scope line. The cockpit passes its own formatter so the
* page shows it in the active language; this module stays i18n-free.
*/
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
installations: ({ scope, coverage }) =>
`${scope} · INSTALLATIONS ${coverage}`,
});
/**
* Format the cockpit Context scope without overstating installation coverage.
* @param {string|number} subjectLabel
* @param {number} radiusM
* @param {string|null} [installationCoverage]
* @param {{window: Function, installations: Function}} [format] Builds the
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
* `{ scope, coverage }`.
* @returns {string}
*/
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
format = ENGLISH_CONTEXT_SCOPE,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
@@ -97,12 +118,12 @@ export function formatCockpitContextScope(
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
? Math.round(radiusM / 1000)
: null;
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
const base = format.window({ label: normalizedLabel, radiusKm });
return coverage ? format.installations({ scope: base, coverage }) : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
+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);
+114 -24
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -39,6 +40,57 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
// Authored English in public/events/bhote-koshi-2026/event.json. The pack is
// data and stays English; these known strings translate at display. Anything
// else (a newer pack, a credit, a source's own title) shows as authored. A
// string that is not known whole translates by its " · " parts.
const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'),
msg('Bhote Koshi valley footage'),
msg('Flood footage'),
msg('Compilation and casualty claims'),
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
msg('SOURCE GEOCONFIRMED'),
// Reconstruction caveat.
msg(
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
),
// Evidence titles that are more than a place name.
msg('Immediate collapse viewpoint'),
msg('Collapse viewpoint'),
msg('Debris-dammed lake'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Gyirong border gate'),
msg('Border gate'),
msg('Timure evidence cluster'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Dandagaun viewpoint'),
msg('Bidur - Trishuli bridge'),
msg('Bidur bridge'),
msg('Devighat / Taadi Khola Bridge'),
msg('Devighat bridge'),
]);
/**
* Display text for an authored event-pack string.
* @param {unknown} value
* @returns {string}
*/
export function bhoteEventText(value) {
const text = value == null ? '' : String(value);
if (EVENT_PACK_TEXT.has(text)) return t(text);
if (!text.includes(' · ')) return text;
return text
.split(' · ')
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
.join(' · ');
}
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -240,7 +292,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
else finish(new Error(msg('Facebook player API did not initialize.')));
}
};
globalRef.fbAsyncInit = ready;
@@ -256,12 +308,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
() => finish(new Error(msg('Facebook player API could not load.'))),
{ once: true },
);
timer =
globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
() => finish(new Error(msg('Facebook player API timed out.'))),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
@@ -398,7 +450,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
else finish(new Error(msg('X embed API did not initialize.')));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
@@ -411,7 +463,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('X embed could not load.')),
() => finish(new Error(msg('X embed could not load.'))),
{
once: true,
},
@@ -440,7 +492,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
() => finish(new Error(msg('X embed timed out.'))),
EMBED_TIMEOUT_MS,
);
}).catch((error) => {
@@ -450,9 +502,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
return _xWidgetsPromise;
}
// Outcomes a trimmed YouTube clip reports to the card status.
const YOUTUBE_PHASE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
timeout: msg('TIMEOUT'),
blocked: msg('BLOCKED'),
});
function providerLabel(provider) {
return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
t('SOURCE')
);
}
@@ -737,7 +797,9 @@ export function createBhoteKoshiEmbeddedMedia({
globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
msg(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
),
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
@@ -857,7 +919,9 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -868,7 +932,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
current.status.textContent = t('LOADING X POST…');
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -885,13 +949,17 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (token !== generation || record !== current) return;
if (!widget)
throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
position();
}
}
@@ -904,19 +972,23 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
);
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
@@ -968,7 +1040,12 @@ export function createBhoteKoshiEmbeddedMedia({
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
const leader = documentRef.createElementNS(
'http://www.w3.org/2000/svg',
@@ -982,17 +1059,19 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(
observation?.shortTitle || observation?.title || 'Witness source',
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
link.textContent = t('OPEN ORIGINAL ↗');
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1032,7 +1111,12 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1049,7 +1133,9 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
}
position();
},
@@ -1067,7 +1153,11 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
},
});
}
+196 -71
View File
@@ -19,11 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
return Math.max(0, Math.min(beats.length - 1, current + delta));
}
// Evidence phases from the event pack, shown as the beat meta prefix.
const EVIDENCE_PHASE_LABELS = Object.freeze({
initiation: msg('INITIATION'),
damming: msg('DAMMING'),
'secondary failure': msg('SECONDARY FAILURE'),
'border arrival': msg('BORDER ARRIVAL'),
propagation: msg('PROPAGATION'),
'downstream passage': msg('DOWNSTREAM PASSAGE'),
consequence: msg('CONSEQUENCE'),
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
});
/** Display label for an evidence phase; unknown phases show as authored. */
function evidencePhaseLabel(phase) {
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
if (label) return t(label);
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
}
/**
* Localized name and date of an imagery endpoint for display.
* @param {{label?: string, observedAt?: string}} entry
* @returns {{name: string, date: string}}
*/
export function imageryEndpointLabel(entry) {
const label = String(entry?.label || '');
const split = label.lastIndexOf(' · ');
const authoredName = split >= 0 ? label.slice(0, split) : label;
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
let name = authoredName;
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
const match = pattern.exec(authoredName);
if (match) name = t(message, { year: match[1] });
}
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
}
function imageryEndpointText(entry) {
const { name, date } = imageryEndpointLabel(entry);
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
return count > 1
? tn(
count,
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
)
: '';
}
/** Interpolate headings across north using the shortest angular path. */
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
}
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
ctx.fillText('NO PREVIEW', 20, 30);
ctx.fillText(t('NO PREVIEW'), 20, 30);
}
function ellipsizeEvidenceText(value, limit) {
@@ -704,7 +783,7 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
observation.shortTitle || observation.title,
bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
@@ -713,10 +792,12 @@ export function updateEvidenceFrame(
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
const publisher = String(
observation.media?.publisher || 'SOURCE LINK',
observation.media?.publisher || t('SOURCE LINK'),
).toUpperCase();
ctx.fillText(
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
t('{publisher} / OPEN', {
publisher: ellipsizeEvidenceText(publisher, 24),
}),
16,
infoY + 53,
);
@@ -813,28 +894,40 @@ function collectPanelReferences(panel) {
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
@@ -842,44 +935,44 @@ function createPanel(event, handlers) {
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">▶ PLAY</button>
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
<button type="button" data-action="story-replay">↺ FULL STORY</button>
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -887,8 +980,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = report.label;
status.textContent = report.status;
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -1205,7 +1298,7 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.show?.({
observation: {
title: witness.title,
shortTitle: 'Rasuwagadhi witness',
shortTitle: t('Rasuwagadhi witness'),
media: { sourceUrl: witness.sourceUrl },
},
anchor,
@@ -2320,7 +2413,10 @@ export function createBhoteKoshiEventLayer({
collisionGroup: 'ambient-card',
zIndex: 60,
interactive: true,
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
{ source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
maxDistance: 220000,
@@ -2353,7 +2449,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2559,11 +2655,14 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
t(
'A historical reference {before} percent, B post-event {after} percent',
{ before: beforePercent, after: afterPercent },
),
onChange: setSplit,
});
}
@@ -2595,7 +2694,7 @@ export function createBhoteKoshiEventLayer({
setElementProperty(
button,
'textContent',
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2961,13 +3060,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.progressInput,
'aria-label',
sceneDirected
? 'Seek Nepal scene clock'
: 'Schematic downstream progression',
? t('Seek Nepal scene clock')
: t('Schematic downstream progression'),
);
setElementProperty(
_panelRefs.timelineLabel,
'textContent',
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
);
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
setElementProperty(
@@ -2980,22 +3079,22 @@ export function createBhoteKoshiEventLayer({
);
const playLabel = sceneDirected
? _sceneActionPending && _sceneActionPending.type !== 'scene'
? '… PLAYING SHOT'
: '▶ PLAY SHOT'
? t('… PLAYING SHOT')
: t('▶ PLAY SHOT')
: _playing
? 'Ⅱ PAUSE'
? t('Ⅱ PAUSE')
: _progress >= 1
? _cinematicReplayArmed
? '↺ REPLAY CINEMATIC'
: '↺ REPLAY'
: '▶ PLAY';
? t('↺ REPLAY CINEMATIC')
: t('↺ REPLAY')
: t('▶ PLAY');
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
setElementProperty(
_panelRefs.playSceneButton,
'textContent',
_sceneActionPending?.type === 'scene'
? '… PLAYING SCENE'
: '▶ PLAY SCENE',
? t('… PLAYING SCENE')
: t('▶ PLAY SCENE'),
);
setElementProperty(
_panelRefs.playSceneButton,
@@ -3011,19 +3110,24 @@ export function createBhoteKoshiEventLayer({
setElementAttribute(
_panelRefs.playSceneButton,
'aria-label',
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
t('Continue {scene} from next shot', {
scene:
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('current scene'),
}),
);
setElementProperty(
_panelRefs.storyReplayButton,
'textContent',
'↺ FULL STORY',
t('↺ FULL STORY'),
);
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
setElementAttribute(
_panelRefs.storyReplayButton,
'aria-label',
'Replay the full reconstruction',
t('Replay the full reconstruction'),
);
}
const beatIndex = currentEvidenceIndex();
@@ -3047,18 +3151,26 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
? _sceneContext?.shotTitle || 'SCENE SHOT'
: observation?.shortTitle || observation?.title || 'EVIDENCE',
? _sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('SCENE SHOT')
: observation?.shortTitle || observation?.title
? bhoteEventText(observation.shortTitle || observation.title)
: t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
const beatMeta = [
String(observation?.phase || 'EVIDENCE').toUpperCase(),
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
evidencePhaseLabel(observation?.phase),
t('TIME UNVERIFIED'),
sourceCount > 1
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
: '',
observation?.imageryCoverage === 'outside'
? t('OUTSIDE IMAGERY SWIPE')
: '',
]
.filter(Boolean)
.join(' / ');
@@ -3066,7 +3178,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatMeta,
'textContent',
sceneDirected
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
? t('{scene} / AUTHORED SHOT', {
scene: String(
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
);
setElementProperty(
@@ -3093,18 +3211,23 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playButton,
'aria-label',
sceneDirected
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
: 'Play reconstruction',
? t('Play {shot}', {
shot:
_sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('current scene shot'),
})
: t('Play reconstruction'),
);
setElementAttribute(
_panelRefs.previousButton,
'aria-label',
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
);
setElementAttribute(
_panelRefs.nextButton,
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
);
setElementProperty(
_panelRefs.openSourceButton,
@@ -3153,8 +3276,8 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
@@ -3717,7 +3840,9 @@ export function createBhoteKoshiEventLayer({
count: _enabled ? 1 : 0,
status: _enabled ? 'nominal' : 'idle',
source: 'Vantor + GeoPera',
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
coverage: _enabled
? t('Rasuwa · 26 Aug 2026')
: t('Event reconstruction'),
};
},
getPlaybackState() {
+59 -24
View File
@@ -12,6 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.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 =
@@ -51,23 +52,43 @@ 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,
heightM: 5200,
title: 'BHOTE KOSHI FLOOD INCIDENT',
subtitle: 'NEPAL · 26 AUG 2026',
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
subtitle: msg('NEPAL · 26 AUG 2026'),
});
const TRIGGER_RECORD = Object.freeze({
lat: 28.271,
lon: 85.515,
heightM: 4200,
title: 'USGS INITIAL REPORT',
title: msg('USGS INITIAL REPORT'),
details: Object.freeze([
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
msg('26 AUG 2026 · 08:37 NPT'),
msg('28.2710° N · 85.5150° E'),
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
]),
});
const TRIGGER_APPROACH_CAMERA = Object.freeze({
@@ -90,11 +111,13 @@ const NEPAL = Object.freeze({
lat: 28.3949,
lon: 84.124,
heightM: 6800,
// i18n-ignore: country name, shown as is
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
Object.freeze({
id: 'kathmandu',
// i18n-ignore: city name
title: 'KATHMANDU',
lat: 27.7017,
lon: 85.3206,
@@ -103,6 +126,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'bhaktapur',
// i18n-ignore: city name
title: 'BHAKTAPUR',
lat: 27.673,
lon: 85.43,
@@ -111,6 +135,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'dhulikhel',
// i18n-ignore: city name
title: 'DHULIKHEL',
lat: 27.6221,
lon: 85.5428,
@@ -394,8 +419,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: subject.title,
details: [subject.subtitle],
title: t(subject.title),
details: [t(subject.subtitle)],
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -536,8 +561,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: TRIGGER_RECORD.title,
details: [...TRIGGER_RECORD.details],
title: t(TRIGGER_RECORD.title),
details: TRIGGER_RECORD.details.map((line) => t(line)),
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -815,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,
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
chips: [
{
id: 'nepal-context',
// i18n-ignore: country name
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
title:
title: t(
'Show the Nepal context callout without a border or country fill',
),
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
// i18n-ignore: river name (Bhote Koshi)
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
title:
title: t(
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
),
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
label: t('CITIES'),
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
title:
title: t(
'Keep the regional incident context and introduce nearby city labels',
),
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
label: t('PLACES'),
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
title:
title: t(
'Introduce the reconstruction scene key places from trigger to downstream',
),
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
label: t('PATH'),
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
title:
title: t(
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
),
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
id: 'bhote-koshi-path-overview',
label: 'OVERVIEW',
label: t('OVERVIEW'),
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
title: 'Keep the completed flood path and location labels visible',
title: t(
'Keep the completed flood path and location labels visible',
),
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
},
{
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
label: t('RECORD'),
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
title:
title: t(
'Reveal the trigger record while making a slow camera approach',
),
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
+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);
});
+98 -4
View File
@@ -27,6 +27,13 @@ import {
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import { instructionFor } from './routeSteps.js';
import { setLocale } from '../i18n/index.js';
import {
directionsInstruction,
routeDistanceText,
routeDurationText,
} from '../layers/directions/index.js';
import {
claimPointer,
isPointerFree,
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'21\u00a0km · 25\u00a0min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
@@ -773,3 +780,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
const types = [
'depart',
'arrive',
'turn',
'new name',
'continue',
'end of road',
'fork',
'merge',
'on ramp',
'off ramp',
'roundabout',
'rotary',
'roundabout turn',
'exit roundabout',
'exit rotary',
'use lane',
'notification',
'something new',
];
const modifiers = [
null,
'left',
'right',
'slight left',
'slight right',
'sharp left',
'sharp right',
'straight',
'uturn',
];
const roads = [
{ name: '', ref: null },
{ name: 'Congress Avenue', ref: null },
{ name: 'Mannerheimintie', ref: 'E12' },
{ name: '', ref: 'I 35' },
];
for (const type of types)
for (const modifier of modifiers)
for (const road of roads)
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
const step = { type, modifier, exit, ...road };
assert.equal(
directionsInstruction(step),
instructionFor(step),
JSON.stringify(step),
);
}
// An older payload without a maneuver type keeps the proxy's sentence.
assert.equal(
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
'Head out on Kaivokatu',
);
});
test('route numbers follow the page language and never break from their unit', (t) => {
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
assert.equal(routeDurationText(Number.NaN), '');
setLocale('ru');
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
assert.equal(routeDurationText(240), '4\u00a0мин');
assert.equal(
directionsInstruction({
type: 'turn',
modifier: 'right',
name: 'East 7th Street',
}),
'Поверните направо на East 7th Street',
);
assert.equal(
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
'На кольце сверните на 2-й съезд',
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
});
+6 -3
View File
@@ -1,3 +1,6 @@
// Marks restore labels for i18n extraction; shareRestoration translates them.
// Kept local so this module stays free of the i18n import.
const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
@@ -458,19 +461,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedFlightsTrackingId',
expiryWindowMs: 90_000,
label: 'flight',
label: msg('flight'),
}),
military: Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedMilitaryTrackingId',
expiryWindowMs: 45_000,
label: 'military flight',
label: msg('military flight'),
}),
satellites: Object.freeze({
optionOwner: 'satellites',
optionKey: 'selectedSatTrackingId',
expiryWindowMs: 300_000,
label: 'satellite',
label: msg('satellite'),
}),
});
+17 -12
View File
@@ -26,47 +26,47 @@ import { t, msg } from '../i18n/index.js';
*/
export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({
label: 'STATION',
label: 'STATION', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in.
color: '#fff6e5',
blurb: 'Crewed stations and their visiting vehicles',
blurb: 'Crewed stations and their visiting vehicles', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}),
nav: Object.freeze({
label: 'NAV',
label: 'NAV', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}),
geo: Object.freeze({
label: 'GEO',
label: 'GEO', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale.
color: '#c89bff',
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
blurb: 'Geostationary belt — comms and weather, fixed over the equator', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}),
visual: Object.freeze({
label: 'VISUAL',
label: 'VISUAL', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance.
color: '#9fb3c4',
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
blurb: 'Brightest naked-eye objects — CelesTrak visual group', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}),
comms: Object.freeze({
label: 'COMMS',
label: 'COMMS', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
// Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f',
blurb: 'Broadband constellation shell — shown only in DENSE mode',
blurb: 'Broadband constellation shell — shown only in DENSE mode', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
}),
});
@@ -167,9 +167,14 @@ export function satelliteClassColor(group) {
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
* @returns {string} Display label.
*/
export function satelliteClassLabel(group, { isIss = false } = {}) {
export function satelliteClassLabel(
group,
{ isIss = false, localize = true } = {},
) {
const { klass, subtype } = satelliteClassOf(group, { isIss });
const base = t(CLASS_DISPLAY_KEYS[klass].label);
// localize: false keeps the English label for data the AI tools query.
const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base;
}
+483 -144
View File
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
rememberFirstRunSessionDismissed(session);
assert.equal(session.read(), 'dismissed');
// Gone for THIS session...
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: session,
location: { search: '' },
}),
false,
);
// ...and back in the next one, because sessionStorage did not survive it.
assert.equal(shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}),
true,
);
// Session dismissal must never have written the durable key.
assert.equal(storage.read(), null);
});
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
setFirstRunSuppressed(true, storage);
assert.equal(storage.read(), 'suppressed');
assert.equal(shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), false);
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}),
false,
);
// Unticking before dismissing takes the suppression back.
setFirstRunSuppressed(false, storage);
assert.equal(storage.read(), null);
assert.equal(shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}),
true,
);
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
setFirstRunSuppressed(true, storage);
assert.equal(shouldShowFirstRun({
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
assert.equal(
shouldShowFirstRun({
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}),
true,
);
});
test('welcome params work in both directions and outrank both suppressions', () => {
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
// ?welcome=1 replays past the checkbox AND past a session dismissal.
assert.equal(shouldShowFirstRun({
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
}), true);
assert.equal(
shouldShowFirstRun({
storage: suppressed,
sessionStorageRef: dismissed,
location: { search: '?welcome=1' },
}),
true,
);
// ?welcome=0 suppresses a session that would otherwise see it.
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
assert.equal(
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
false,
);
// A share link outranks everything, including the replay hatch.
assert.equal(shouldShowFirstRun({
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
}), false);
assert.equal(
shouldShowFirstRun({
hasShareState: true,
...fresh(),
location: { search: '?welcome=1' },
}),
false,
);
});
test('a share link never sees the launcher — its author already chose the view', () => {
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
test('privacy-restricted storage fails open and every write stays best-effort', () => {
const blocked = {
getItem: () => { throw new Error('blocked'); },
setItem: () => { throw new Error('blocked'); },
removeItem: () => { throw new Error('blocked'); },
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
removeItem: () => {
throw new Error('blocked');
},
};
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
assert.equal(
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
true,
);
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
});
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
// before the function body starts, so the SecurityError escapes every
// try/catch in the module and the launcher silently never appears.
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
const savedSession = Object.getOwnPropertyDescriptor(
globalThis,
'sessionStorage',
);
const hostile = {
configurable: true,
get() { throw new Error('SecurityError: The operation is insecure.'); },
get() {
throw new Error('SecurityError: The operation is insecure.');
},
};
Object.defineProperty(globalThis, 'localStorage', hostile);
Object.defineProperty(globalThis, 'sessionStorage', hostile);
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
} finally {
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
else delete globalThis.localStorage;
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
if (savedSession)
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
else delete globalThis.sessionStorage;
}
});
test('no storage is touched from a default parameter position', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// Comments stripped first: the block explaining this very defect quotes the
// bad pattern, and matching prose instead of code would make the pin a liar.
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const code = module
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(
code,
/=\s*globalThis\.(local|session)Storage/,
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
);
// The only reads of the global areas live inside the guarded resolver.
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
assert.equal(
globalReads.length,
2,
'exactly two global storage reads, both in resolveStore',
);
const resolver = code.slice(
code.indexOf('function resolveStore'),
code.indexOf('function readStored'),
);
assert.equal(
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
2,
'both global storage reads must be inside resolveStore, inside its try',
);
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
assert.match(
resolver,
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
);
assert.match(code, /function readStored\(kind, injected, key\)/);
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
});
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.deepEqual(
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
);
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
'cockpit-mode',
'recording-mode',
'scene-playback-mode',
'ui-clean-view',
]);
// A surface that hides the card in CSS but is missing from the JS list would
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
const hideRule = css.slice(
css.indexOf('body.ui-clean-view #first-run-launcher'),
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
css.indexOf(
'display: none',
css.indexOf('body.ui-clean-view #first-run-launcher'),
),
);
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
const inCss = [
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
].map((m) => m[1]);
assert.deepEqual(
inCss.sort(),
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
'every screen-claiming surface must appear in BOTH lists',
);
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
assert.match(
module,
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
);
});
test('exclusiveSurfaceActive reads the live body classes', () => {
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
const make = (classes) => ({
body: { classList: { contains: (name) => classes.includes(name) } },
});
assert.equal(exclusiveSurfaceActive(make([])), false);
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
assert.equal(
exclusiveSurfaceActive(make([name])),
true,
`${name} must count as exclusive`,
);
}
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
assert.equal(exclusiveSurfaceActive(undefined), false);
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
});
test('the key handler refuses to act for a card that is not really on screen', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
// Real visibility, not just the class: the class survives while CSS hides the
// card, which is precisely how a Scene left an invisible ESC handler armed.
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
assert.match(
module,
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
);
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
const handler = keyboard.slice(
keyboard.indexOf('const onKeyDown = (event) => {'),
);
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/!isActive\(\)/,
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
});
test('an overlay with NO class to watch still disarms the launcher', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// The repro, pinned as the stacking it actually is: the attribution lightbox
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
// buried launcher and wrote the session flag while the lightbox stayed open.
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
assert.match(
overlay.slice(0, overlay.indexOf('}')),
/z-index: 200 !important/,
);
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
// launcher acting under an overlay can never become why ESC stopped working.
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
assert.match(
covered,
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
);
assert.match(
covered,
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
);
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(
new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself');
assert.doesNotMatch(
claim,
/event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself',
);
// Belt on the launcher side: a key another surface already marked is not ours,
// whether or not that surface remembered to silence us.
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
const handler = keyboard.slice(
keyboard.indexOf('const onKeyDown = (event) => {'),
);
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/event\.defaultPrevented\) return;/,
'a marked key must be somebody else\'s key',
"a marked key must be somebody else's key",
);
});
test('a refused write takes the tick back instead of promising "never again"', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The write stays best-effort; the OUTCOME is now reported, because a box left
// ticked after a refused write tells the visitor the launcher is gone for good
// while it is already guaranteed to return next session.
const blocked = {
getItem: () => null,
setItem: () => { throw new Error('blocked'); },
removeItem: () => { throw new Error('blocked'); },
setItem: () => {
throw new Error('blocked');
},
removeItem: () => {
throw new Error('blocked');
},
};
assert.equal(setFirstRunSuppressed(true, blocked), false);
assert.equal(setFirstRunSuppressed(false, blocked), false);
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
module.indexOf('const onSuppressChange = (event) => {'),
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
);
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
assert.match(
handler,
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
);
assert.match(
handler,
/box\.checked = !wanted;/,
'a refused write must revert the tick',
);
});
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const state = fs.readFileSync(
new URL('../docs/CURRENT-STATE.md', import.meta.url),
'utf8',
);
// A "reveal anyway after N seconds" would trade a benign no-show for the card
// punching through a recording in progress — recordings run long, and none of
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
module.indexOf(' const onViewportResize'),
);
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
'the pin must cover the function the note governs, not just the note');
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
'the acceptance is the decision NOT to time this out');
assert.ok(
accepted.length > 0,
'the acceptance must be written where the next editor will read it',
);
assert.match(
accepted,
/const syncToExclusiveSurfaces = \(\) => \{/,
'the pin must cover the function the note governs, not just the note',
);
assert.doesNotMatch(
accepted,
/setTimeout|setInterval/,
'the acceptance is the decision NOT to time this out',
);
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
assert.match(state, /a surface class that never clears means no launcher for that page/i);
assert.match(
state,
/a surface class that never clears means no launcher for that page/i,
);
});
test('the scroll fade only appears when the list really overflows', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
assert.match(
module,
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
);
assert.match(
module,
/choiceList\.dataset\.scrollable = String\(overflows\);/,
);
// ...and the CSS must be gated on that flag, never on the media query alone.
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
assert.match(
css,
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
);
const bareFade =
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
assert.doesNotMatch(
bareFade,
/mask-image/,
'the fade must never apply unconditionally',
);
// Rotating a phone changes which tiles fit, so it re-measures.
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
});
test('the launcher yields on engage and waits when a surface is already up', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// Both directions from one observer: yield if it is up and something takes
// the screen; wait to reveal if something already has it.
assert.match(
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
// not see a new hold because of onboarding chrome.
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
assert.doesNotMatch(
module,
/setInterval|requestAnimationFrame\(function poll/,
);
});
// ── Per-mission behavior ─────────────────────────────────────────────────────
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
function missionSpy({
contextOk = true,
layerResult = () => true,
globe = async () => ({ ok: true }),
} = {}) {
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
return {
calls,
deps: {
setContextMode: async (mode) => {
calls.contextModes.push(mode);
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
return contextOk
? { ok: true, mode }
: { ok: false, failedLayerIds: ['rocket-launches'] };
},
setLayerEnabled: async (layerId) => {
calls.layerIds.push(layerId);
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
// went is the one-click globe-scale dump. Restoring the tile needs the
// globe-LOD declutter first.
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
'contacts', 'space-missions', 'environmental', 'explore',
'contacts',
'space-missions',
'environmental',
'explore',
]);
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
'the infrastructure mission must be gone, not dormant');
assert.equal(
FIRST_RUN_MISSIONS.infrastructure,
undefined,
'the infrastructure mission must be gone, not dormant',
);
});
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
for (const [choice, mode] of [
['contacts', 'contacts'],
['space-missions', 'space-missions'],
]) {
const spy = missionSpy();
const outcome = await runFirstRunChoice(choice, spy.deps);
assert.equal(outcome.ok, true);
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
// post-launch, and the note must stay where the next editor will read it
// rather than being re-discovered as a launcher bug.
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const table = module.slice(
module.indexOf(' environmental: Object.freeze({'),
module.indexOf(' explore:'),
);
assert.match(table, /KEY REQUIRED/);
assert.match(table, /src\/loadingFeedback\.js/);
assert.match(table, /LEDGERED post-launch/);
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
assert.equal(outcome.ok, true);
assert.deepEqual(outcome.failedLayerIds, []);
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(
module.slice(module.indexOf('export async function runFirstRunChoice')),
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
// The globe flight is framing. A cancelled or throwing flight is not a failure.
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
const flightDown = missionSpy({
globe: () => {
throw new Error('cancelled');
},
});
assert.equal(
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
true,
);
});
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
const spy = missionSpy();
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
assert.deepEqual(spy.calls, {
contextModes: [],
layerIds: [],
globeFlights: 0,
});
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
assert.deepEqual(spy.calls, {
contextModes: [],
layerIds: [],
globeFlights: 0,
});
});
test('a failed Context mission reports the layers the facade named', async () => {
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
assert.equal(
environmentalLabel().title,
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
);
});
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
test('no mission writes a preference the visitor did not choose by picking it', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const code = module.slice(
module.indexOf('export function shouldShowFirstRun'),
);
// Layer enables ARE durable in this app and a mission tile IS that choice, so
// they run at the same origin a click on those rows uses.
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
'_setModels3dParams',
'setFeather',
]) {
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
assert.doesNotMatch(
code,
new RegExp(forbidden),
`a mission must never touch ${forbidden}`,
);
}
// The only durable panel write is the Context reveal, and only on the Context
// missions — the globe missions open no panel at all.
const panelWrites = code.match(/setPanelCollapsed/g) || [];
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
assert.equal(
panelWrites.length,
1,
'exactly one panel reveal, on the Context path',
);
const contextPath = code.slice(
code.indexOf('setContextMode: async (mode)'),
code.indexOf('setLayerEnabled:'),
);
assert.match(
contextPath,
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
);
});
test('the decision table is written down where the next editor will read it', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.match(
module,
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
);
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
assert.ok(
module.includes(row),
`decision table is missing its "${row}" rows`,
);
}
assert.match(module, /SHOW POLICY/);
});
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
// ── Markup, startup ordering, accessibility ─────────────────────────────────
test('markup, startup ordering and accessibility remain pinned', () => {
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
const html = expandApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const startup = fs.readFileSync(
new URL('./app/startupChrome.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
assert.match(
html,
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
);
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
assert.match(
html,
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
);
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
assert.match(html, /<strong data-first-run-environmental-title>/);
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
// half of what it does is the defect this replaced. Only the VISIBLE <small>
// text counts; the comment beside it naturally says the words too.
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
const envTile = html.slice(
html.indexOf('data-first-run-choice="environmental"'),
);
const visible = envTile.slice(
envTile.indexOf('<small>'),
envTile.indexOf('</small>'),
);
assert.match(visible, /earthquakes/i);
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
assert.match(
visible,
/fires?/i,
'the tile must promise the fires it enables',
);
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
// unspaced em dash included. This is copy, not prose to be improved in a
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
+ '—then you realize the sources are public and the data is real.</p>'),
html.includes(
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
'—then you realize the sources are public and the data is real.</p>',
),
'the owner-authored first-run line must ship exactly as written',
);
// Menu order is the owner's, read straight off the markup.
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind');
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
(match) => match[1],
);
assert.deepEqual(order, [
'contacts',
'space-missions',
'environmental',
'explore',
]);
assert.doesNotMatch(
html,
/data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind',
);
assert.match(startup, /styleManager\.initialRestorePromise/);
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
assert.ok(
startup.indexOf("loadingScreen.classList.add('hidden')") <
startup.indexOf(
"loadingScreen.addEventListener('transitionend', revealFirstRun",
),
);
assert.match(
startup,
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
);
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
assert.match(css, /body\.recording-mode #first-run-launcher/);
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
// matching one of THOSE would let the launcher's own opt-out be deleted.
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
.map((match) => match[1]);
const reducedMotion = [
...css.matchAll(
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
),
].map((match) => match[1]);
assert.ok(
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
reducedMotion.some(
(block) =>
block.includes('#first-run-launcher') &&
block.includes('.first-run-choices'),
),
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
);
// The card must be click-through until revealed and again while it fades out.
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
const base = css.slice(
css.indexOf('#first-run-launcher {'),
css.indexOf('#first-run-launcher.visible'),
);
assert.match(base, /pointer-events: none/);
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
assert.match(
css,
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
);
// The card is a flex column so its mission list can scroll on a short
// viewport — and an AUTHOR `display` on this id outranks the UA's
// `[hidden] { display: none }`, which would strand the card in the
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
});
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// aria-disabled, never the `disabled` property: disabling a focused button
// drops the keyboard to <body> and strands the visitor outside the launcher.
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
assert.match(
module,
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
);
assert.doesNotMatch(module, /button\.disabled = /);
// Tab is confined to the launcher, and ESC always releases it.
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
assert.match(module, /keyboard\.activate\(\)/);
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
assert.match(keyboard, /event\.key !== 'Tab'/);
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
});
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(
new URL('./ui/panelPositionControls.js', import.meta.url),
'utf8',
);
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
// now, so it was opening to offer controls for things already happening —
// while competing with the mission card for the one first impression there is.
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
);
// The first-run default may only apply when NOTHING is stored: a visitor who
// opened the rail keeps it open, and one who closed it keeps it closed.
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
const block = ui.slice(
ui.indexOf(
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
),
);
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
(tool) => tool.name !== 'set_cyber_sonar',
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
.parameters.properties.layout;
assert.deepEqual(hudLayout.enum, [
'tactical',
'operator',
'minimal',
'cyber',
]);
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
const block = JSON.stringify(legacyTools);
@@ -680,7 +1000,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
const instructions = fs.readFileSync(
new URL('../server/providers/openai/instructions.js', import.meta.url),
'utf8',
);
// ...and the mapping that makes them reachable by voice is one instruction
// string, whose rollback is deleting that string. Anchored to a LIVE array
@@ -691,23 +1014,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
/\n\s+'NAMED VIEWS are shorthand/,
'the mission mapping must be an active instruction entry, not commented out',
);
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
const mapping = instructions.slice(
instructions.indexOf('NAMED VIEWS are shorthand'),
);
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
for (const layerId of [
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'earthquakes',
]) {
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
assert.ok(
paragraph.includes(layerId),
`mapping must name the existing ${layerId} enum value`,
);
}
assert.ok(paragraph.includes('zoom_to_globe'));
assert.ok(paragraph.includes('set_layer_visibility'));
});
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
const tool = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'set_layer_visibility',
);
const allowedLayers = tool.parameters.properties.layerId.enum;
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
(mission) => mission.layerIds || [],
);
assert.ok(missionLayerIds.length > 0);
for (const layerId of missionLayerIds) {
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
assert.ok(
allowedLayers.includes(layerId),
`${layerId} must already be an allowed enum value`,
);
}
});
+151 -15
View File
@@ -27,7 +27,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
@@ -35,7 +35,9 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { t } from './i18n/index.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizeLayerName } from './ui/layerPanel.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -61,6 +63,110 @@ const HUD_COLORS = {
},
};
/**
* Mode names shown for each style. Same English as STYLE_STATUS_LABELS in
* ui/visualPresets.js, so the top-left mode and the dock's active style
* translate alike.
*/
const MODE_LABELS = Object.freeze({
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
/** Region ids from _regionLabel() and the name shown for each. */
const REGION_LABELS = Object.freeze({
ARCTIC: msg('ARCTIC'),
ANTARCTIC: msg('ANTARCTIC'),
'NORTH AMERICA': msg('NORTH AMERICA'),
'SOUTH AMERICA': msg('SOUTH AMERICA'),
EUROPE: msg('EUROPE'),
AFRICA: msg('AFRICA'),
ASIA: msg('ASIA'),
OCEANIA: msg('OCEANIA'),
'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
});
/** 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) {
case 'STREET':
return t('STREET', { context: 'view band' });
case 'CITY':
return t('CITY', { context: 'view band' });
case 'METRO':
return t('METRO', { context: 'view band' });
case 'REGIONAL':
return t('REGIONAL', { context: 'view band' });
default:
return t('GLOBAL', { context: 'view band' });
}
}
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
useGrouping: false,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return `${number}${unitLabel(unit).toUpperCase()}`;
}
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
function hudCoordinate(value, positive, negative) {
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
}
/** The summary's locality line in the active language (see hudLocality.js). */
const LOCALIZED_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) =>
t('NEAR {poi} ({city}) {distance}', {
poi,
city,
distance: hudQuantity(Math.round(distKm), 'km'),
}),
sector: ({ latDeg, lonDeg }) =>
t('SECTOR {lat} {lon}', {
lat: hudCoordinate(latDeg, t('N'), t('S')),
lon: hudCoordinate(lonDeg, t('E'), t('W')),
}),
});
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
@@ -186,6 +292,7 @@ export class IntelHUD {
this._el = document.getElementById('intel-hud');
if (!this._el) return;
// i18n-ignore: sensor readout codes (MGRS, GSD, NIIRS, ONA, BAND…) and a classification banner; translated parts use t()
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
@@ -200,7 +307,7 @@ export class IntelHUD {
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
@@ -228,7 +335,7 @@ export class IntelHUD {
<div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div>
<div id="hud-alt">ALT: --m SUN: --° EL</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
</div>
<div class="hud-bracket">┘</div>
</div>
@@ -356,9 +463,11 @@ export class IntelHUD {
const formatted = this._formatMGRS(mgrsStr);
mgrsLabel = formatted;
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor readout codes, same in every language
if (el) el.textContent = `MGRS: ${formatted}`;
} catch {
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor readout codes, same in every language
if (el) el.textContent = 'MGRS: ---';
}
@@ -367,6 +476,7 @@ export class IntelHUD {
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
const bottomEl = document.getElementById('hud-bottom-line');
if (bottomEl) {
// i18n-ignore: sensor readout codes, same in every language
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
}
@@ -382,6 +492,7 @@ export class IntelHUD {
);
const gsdEl = document.getElementById('hud-gsd');
if (gsdEl)
// i18n-ignore: sensor readout codes, same in every language
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
@@ -394,6 +505,7 @@ export class IntelHUD {
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl)
// i18n-ignore: sensor readout codes, same in every language
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
@@ -403,6 +515,7 @@ export class IntelHUD {
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor readout codes, same in every language
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
}
@@ -411,6 +524,7 @@ export class IntelHUD {
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona');
// i18n-ignore: sensor readout codes, same in every language
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
// `altM` stays the raw ellipsoidal camera height the sensor model reads
@@ -638,7 +752,7 @@ export class IntelHUD {
if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode');
const modeLabel = modeEl?.textContent || 'NORMAL';
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
const region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
@@ -650,20 +764,41 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
// Display only: the AI summary request carries its own context, so this
// line is built in the active language.
const altTag =
altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
? hudQuantity(altDisplayM / 1000, 'km', 1)
: hudQuantity(Math.round(altDisplayM), 'm');
const winTag = window
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
: 'N/A';
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
: t('N/A');
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
const localityTag = composeLocalityTag(
nearest,
m.latDeg,
m.lonDeg,
LOCALIZED_LOCALITY,
);
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
const 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: modeLabel,
band: viewBandLabel(band),
locality: localityTag,
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
alt: altTag,
window: winTag,
sun: formatNumber(Math.round(m.sunEl)),
ona: formatNumber(Math.round(m.ona)),
zone: localTag,
},
);
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
return provenance ? `${line} | ${provenance}` : line;
}
@@ -819,8 +954,9 @@ export class IntelHUD {
// Update mode label
const modeEl = document.getElementById('hud-mode');
if (modeEl) {
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
modeEl.textContent = MODE_LABELS[styleName]
? t(MODE_LABELS[styleName])
: styleName.toUpperCase();
}
// Update color scheme
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
+25 -3
View File
@@ -23,6 +23,17 @@
*/
export const NEAR_POI_MAX_KM = 150;
/**
* The English locality line. The HUD passes its own formatter so the page
* shows it in the active language; this default stays i18n-free.
*/
const ENGLISH_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) =>
`NEAR ${poi} (${city}) ${Math.round(distKm)}KM`,
sector: ({ latDeg, lonDeg }) =>
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
});
/**
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
* @param {number} value Signed decimal degrees.
@@ -42,13 +53,24 @@ function coordinateTag(value, positive, negative) {
* catalogue is empty.
* @param {number} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees.
* @param {{near: Function, sector: Function}} [format] Builds the line from
* `{ poi, city, distKm }` (names upper-cased) or `{ latDeg, lonDeg }`.
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`.
*/
export function composeLocalityTag(nearest, latDeg, lonDeg) {
export function composeLocalityTag(
nearest,
latDeg,
lonDeg,
format = ENGLISH_LOCALITY,
) {
const distKm = Number(nearest?.distKm);
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
return format.near({
poi: String(nearest.poi).toUpperCase(),
city: String(nearest.city).toUpperCase(),
distKm,
});
}
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
return format.sector({ latDeg, lonDeg });
}
+51 -9
View File
@@ -17,7 +17,7 @@ test('the NEAR bound is metro scale, not continental', () => {
test('a landmark under the camera reads NEAR', () => {
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
);
assert.equal(
@@ -40,20 +40,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
});
test('the boundary is pinned on both sides, inclusive at the bound', () => {
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
const at = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
40,
-78,
);
assert.match(
at,
/^NEAR LINCOLN MEMORIAL/,
'exactly at the bound still reads NEAR',
);
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
const just_under = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
40,
-78,
);
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
const just_over = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
40,
-78,
);
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
});
test('southern and western hemispheres carry the right suffixes', () => {
// Rio and Honolulu — the two the fallback already handled correctly in the field.
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
assert.equal(
composeLocalityTag(null, -22.9068, -43.1729),
'SECTOR 22.91S 43.17W',
);
assert.equal(
composeLocalityTag(null, 21.3069, -157.8583),
'SECTOR 21.31N 157.86W',
);
});
// The tests above all pass against a hud.js that still computes the tag inline —
@@ -72,7 +94,9 @@ test('hud.js actually composes its summary through this helper', () => {
'hud.js must import composeLocalityTag from ./hudLocality.js',
);
assert.equal(
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
has(
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
),
true,
'_composeSummary must build its locality tag through composeLocalityTag()',
);
@@ -91,6 +115,24 @@ test('hud.js actually composes its summary through this helper', () => {
test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
assert.match(
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
/^SECTOR /,
);
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
});
test('a formatter builds the line from the same parts', () => {
const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
};
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
'near:ALCATRAZ ISLAND|SAN FRANCISCO|2.4',
);
assert.equal(
composeLocalityTag(null, 21.3, -157.8, format),
'sector:21.3|-157.8',
);
});
+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,
);
});
+81
View File
@@ -4,8 +4,11 @@ import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
findUnmarked,
readCatalog,
shippedLocales,
unmarkedMarkup,
unmarkedScript,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
@@ -32,6 +35,13 @@ for (const locale of shippedLocales()) {
});
}
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`),
[],
);
});
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
const fixture = new Map([
['Saved {name}', { plural: false }],
@@ -55,3 +65,74 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', ()
assert.deepEqual(result.stale, ['Removed']);
assert.equal(result.errors.length, 2);
});
test('checkCatalog reports a translation left in English unless it is allowed', () => {
const fixture = new Map([
['Bloom', { plural: false }],
['HDG', { plural: false }],
['voice|READY', { plural: false }],
['{state} · {source}', { plural: false }],
]);
const catalog = {
Bloom: 'Bloom',
HDG: 'HDG',
'voice|READY': 'READY',
'{state} · {source}': '{state} · {source}',
};
const errors = (allowed) =>
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
assert.deepEqual(
errors([]).map((error) => error.split(' is still English')[0]),
['"Bloom"', '"HDG"', '"voice|READY"'],
);
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
});
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
'title="Pin it"',
]);
assert.deepEqual(
texts('<button title="Pin it" data-i18n-title>x</button>'),
[],
);
assert.deepEqual(
texts('<select translate="no"><option>0.9 dB</option></select>'),
[],
);
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
assert.deepEqual(
texts('<span class="material-symbols-outlined">public</span>'),
[],
);
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
'On air',
'Off air',
]);
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
'Open',
]);
assert.deepEqual(
texts("const row = { label: msg('Wind'), id: 'wind' };"),
[],
);
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
assert.deepEqual(texts("el.className = 'is-active';"), []);
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
assert.deepEqual(
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
[],
);
});
+31
View File
@@ -130,6 +130,12 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
});
test('msg() returns its text unchanged', () => {
assert.equal(msg('Live Flights'), 'Live Flights');
});
@@ -182,6 +188,31 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
assert.equal(unknown.textContent, 'No such message');
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
const storage = {
setItem() {
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage();
const calls = [];
+48 -5
View File
@@ -3,6 +3,7 @@
// t('Save keys') → 'Сохранить ключи'
// t('Cleared {name}', { name }) → named interpolation
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
// t('CLEAR', { context: 'action' }) → same English, own entry
// msg('Live Flights') → marks a string kept in data
// for a later t(); returns it as is
//
@@ -76,11 +77,13 @@ function lookup(text) {
/**
* Translate English source text into the active locale.
* @param {string} text English text, e.g. 'Hold Space to talk'
* @param {object} [values] Named interpolation values
* @param {object} [values] Named interpolation values. `context` tells
* apart identical English with different meanings: t('CLEAR', { context:
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
* @returns {string}
*/
export function t(text, values) {
const entry = lookup(text);
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
return interpolate(typeof entry === 'string' ? entry : text, values);
}
@@ -106,6 +109,18 @@ export function tn(count, one, other, values) {
return interpolate(pattern, { ...values, count });
}
/**
* Translate text that may be ours or may come from a provider: a catalog
* entry is used when there is one, anything else shows as given, without a
* missing-translation warning.
*/
export function translateKnown(text) {
const catalog = CATALOGS[currentLocale];
if (!catalog || typeof text !== 'string') return text;
const entry = catalog[text];
return typeof entry === 'string' ? entry : text;
}
/** Mark English text that is stored in data and translated later by t(). */
export function msg(text) {
return text;
@@ -149,6 +164,32 @@ export function formatDate(value, options) {
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
}
const UTC_DAY_MONTH = Object.freeze({
day: '2-digit',
month: '2-digit',
timeZone: 'UTC',
});
/**
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
* English (as before), "13.10" in Russian, "13/10" in Spanish, never read as a
* range. Takes an ISO string (its date part is used as written) or a time.
* @param {string|number|Date} value
* @returns {string} '' when the value is not a time.
*/
export function formatUtcDayMonth(value) {
const written =
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
const time = written
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
}
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
@@ -209,7 +250,7 @@ export function applyDocumentTranslations(doc) {
/**
* Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string, so the override is one-shot.
* Storage is best-effort: where it is blocked the choice will not survive.
* Where storage is blocked, ?lang carries the choice instead.
* @returns {boolean} true when a reload was triggered.
*/
export function persistLocaleAndReload(
@@ -221,12 +262,14 @@ export function persistLocaleAndReload(
} = {},
) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
writeStoredLocale(normalized, storage);
const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false;
let target = location.href;
try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href);
url.searchParams.delete('lang');
if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href;
} catch {
// Keep the current href.
+1200 -76
View File
File diff suppressed because it is too large. Load diff
+1222 -77
View File
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
{
"es": [
"AIS: --",
"AIS: {name}",
"ANIME",
"ASIA",
"AUTO",
"Ambient",
"Anime",
"BRG {value}°",
"BRG —",
"Blues",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"Country",
"DEST ---°",
"DEST {bearing}",
"Dance",
"ENE",
"ERROR",
"ESE",
"Error: {message}",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"Noir",
"Normal",
"POS: {time}Z",
"Plan",
"Pop",
"Punk",
"R&B",
"RADIO",
"RANGE",
"REC",
"Radio",
"Reggae",
"Rock",
"SAR",
"SE",
"SECTOR {lat} {lon}",
"SIG",
"SRC {value}",
"SSE",
"STD",
"Sector",
"Sonar",
"Soul",
"Techno",
"Trance",
"VISUAL",
"VS",
"WHOT/BHOT",
"WX",
"audio {state}",
"dB",
"fire confidence|nominal",
"ft",
"hPa",
"installation class|BASE",
"km",
"km/h",
"min",
"mini",
"mph",
"nominal",
"panel",
"view band|GLOBAL",
"voice|ERROR",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
],
"ru": [
"AIS: --",
"AIS: {name}",
"CCTV",
"CRT",
"FLIR",
"GEO",
"Ironbow",
"Landsat 8/9",
"NAV",
"NVG",
"R&B",
"REC",
"VS",
"WHOT/BHOT"
]
}
+26 -17
View File
@@ -128,12 +128,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
const state = auth.querySelector('.key-setup-cloud-auth-state');
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
state.textContent =
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
mode === 'oauth'
? t('VOICE AUTH · CHATGPT OAUTH')
: t('VOICE AUTH · API KEY');
toggle.textContent =
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
toggle.title =
mode === 'oauth'
? 'Use OPENAI_API_KEY for the next cloud voice session'
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
? t('Use OPENAI_API_KEY for the next cloud voice session')
: t(
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
);
// Keep the control focusable while the delegated handler blocks repeats.
toggle.setAttribute('aria-disabled', String(busy));
}
@@ -153,21 +158,22 @@ function buildRow(documentRef, key) {
led.className = 'key-setup-led';
led.setAttribute('aria-hidden', 'true');
const title = documentRef.createElement('strong');
title.textContent = key.title;
title.textContent = key.title; // i18n-ignore: provider product name
const tier = documentRef.createElement('span');
tier.className = 'key-setup-tier';
tier.textContent = TIER_DOTS[key.tier] || '';
tier.title =
key.tier === 'metered'
? 'Metered — a billing-enabled account'
: 'Free key — register, paste, done';
? t('Metered — a billing-enabled account')
: t('Free key — register, paste, done');
head.append(led, title, tier);
if (key.clientExposed) {
const exposed = documentRef.createElement('span');
exposed.className = 'key-setup-exposed';
exposed.textContent = 'browser-side';
exposed.title =
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
exposed.textContent = t('browser-side');
exposed.title = t(
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
);
head.append(exposed);
}
if (external) {
@@ -175,9 +181,10 @@ function buildRow(documentRef, key) {
// facts this panel reports, never values it rewrites or deletes.
const badge = documentRef.createElement('span');
badge.className = 'key-setup-external';
badge.textContent = 'configured externally';
badge.title =
'Supplied by your environment, Keychain, or launcher — change it where it was set';
badge.textContent = t('configured externally');
badge.title = t(
'Supplied by your environment, Keychain, or launcher — change it where it was set',
);
head.append(badge);
}
const get = documentRef.createElement('a');
@@ -185,7 +192,7 @@ function buildRow(documentRef, key) {
get.href = key.getUrl;
get.target = '_blank';
get.rel = 'noopener noreferrer';
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
head.append(get);
const unlocks = documentRef.createElement('p');
@@ -206,8 +213,8 @@ function buildRow(documentRef, key) {
input.dataset.envVar = envVar;
input.setAttribute('aria-label', envVar);
input.placeholder = key.set
? `${envVar} saved — paste to replace`
: `paste ${envVar}`;
? t('{envVar} saved — paste to replace', { envVar })
: t('paste {envVar}', { envVar });
fields.append(input);
}
if (key.managed === 'file') {
@@ -328,7 +335,9 @@ export async function initKeySetup({
if (disposed) return;
status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status);
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
chip.title = t('Project keys: {status}', {
status: keySetupChipLabel(status),
});
chip.setAttribute('aria-label', chip.title);
// Fully powered is the owner's clean screen: the chip retires. The dialog
// stays reachable this session (and via ?setup=1) to swap or verify keys.
+10 -32
View File
@@ -39,7 +39,7 @@ const msg = (text) => text;
export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS',
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
@@ -48,7 +48,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'google-maps-server',
title: 'GOOGLE MAPS — SERVER',
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
unlocks: msg(
'Places context + Street View fallback; optional separate key',
),
@@ -60,7 +60,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'openai',
title: 'OPENAI',
title: 'OPENAI', // i18n-ignore: provider product name
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
@@ -68,7 +68,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'aisstream',
title: 'AISSTREAM',
title: 'AISSTREAM', // i18n-ignore: provider product name
unlocks: msg('Live ships, worldwide'),
getUrl: 'https://aisstream.io',
envVars: Object.freeze(['AISSTREAM_API_KEY']),
@@ -76,7 +76,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'firms',
title: 'NASA FIRMS',
title: 'NASA FIRMS', // i18n-ignore: provider product name
unlocks: msg('Live active-fire detections'),
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
envVars: Object.freeze(['FIRMS_MAP_KEY']),
@@ -84,7 +84,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'tomtom',
title: 'TOMTOM',
title: 'TOMTOM', // i18n-ignore: provider product name
unlocks: msg('Real live traffic (keyless runs a simulation)'),
getUrl: 'https://my.tomtom.com/keys',
envVars: Object.freeze(['TOMTOM_API_KEY']),
@@ -92,7 +92,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'cesium-ion',
title: 'CESIUM ION',
title: 'CESIUM ION', // i18n-ignore: provider product name
unlocks: msg('Bing imagery map stacks + world terrain'),
getUrl: 'https://ion.cesium.com/tokens',
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
@@ -101,7 +101,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'opensky',
title: 'OPENSKY',
title: 'OPENSKY', // i18n-ignore: provider product name
unlocks: msg('More flight-polling credits (anonymous works without)'),
getUrl: 'https://opensky-network.org',
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
@@ -109,7 +109,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY',
title: 'MAPILLARY', // i18n-ignore: provider product name
unlocks: msg(
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
),
@@ -120,7 +120,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY',
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
unlocks: msg('Higher space-missions request allowance'),
getUrl: 'https://thespacedevs.com',
envVars: Object.freeze(['LL2_API_TOKEN']),
@@ -394,28 +394,6 @@ export function keySetupWithAlternatives(requirement, alternatives = []) {
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
}
/**
* Alternative sources plus the registry name of the key each would use, as
* machine values for presentation layers that localize the guidance sentence
* themselves (this module also runs in node-side key-setup builds, which stay
* English).
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
* @returns {Array<{label: string, name: string|null}>}
*/
export function keySetupAlternativeKeyNames(alternatives = []) {
return alternatives.map(({ label, requiresKeyId }) => {
const entry = KEY_SETUP_KEYS.find(
(candidate) => candidate.id === requiresKeyId,
);
const name = entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
return { label, name };
});
}
/**
* Decide whether a live provider value belongs to a source outside the store
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
+33 -19
View File
@@ -18,6 +18,7 @@ import {
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
import { t, tn, msg } from '../../i18n/index.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
id: LAYER_ID,
name: 'ALPR Cameras',
icon: '📷',
source: source.label || 'Mapped camera locations',
// English in layer state; the layer row translates it.
source: source.label || msg('Mapped camera locations'),
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
label: t('SHOW NEAREST'),
title: state.viewer?.trackedEntity
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
? t(
'Stop following the current object before navigating to a camera',
)
: t('Move to the nearest loaded camera and show its details'),
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
legend: [
{
label: 'Camera badges',
label: t('Camera badges'),
color: ALPR_COLOR,
count,
blurb:
blurb: t(
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
),
},
],
};
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
count: state.dataSource?.entities.values.length || 0,
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
}`
? Number.isInteger(state.onScreen)
? tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby · {onScreen} on screen',
'{count} nearby · {onScreen} on screen',
{ onScreen: state.onScreen },
)
: tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby',
'{count} nearby',
)
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
: 0,
loadingLabel: state.loading
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
? t('retrying mapped ALPR cameras')
: t('loading mapped ALPR cameras')
: state.noCoverage
? 'No ALPR data for this area — US and Canada only'
? t('No ALPR data for this area — US and Canada only')
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
? t('Zoom in to load mapped cameras')
: [
state.stale ? 'Showing cached locations' : '',
state.stale ? t('Showing cached locations') : '',
state.saturated || state.renderSaturated
? 'Coverage limited — zoom in'
? t('Coverage limited — zoom in')
: '',
state.status === 'empty'
? t('No ALPR data for this area')
: '',
state.status === 'empty' ? 'No ALPR data for this area' : '',
state.status === 'ready' && state.onScreen === 0
? 'None on screen — nearby cameras are outside the view'
? t('None on screen — nearby cameras are outside the view')
: '',
]
.filter(Boolean)
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
label: t('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+5 -2
View File
@@ -16,6 +16,9 @@ import {
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
@@ -184,7 +187,7 @@ export function createAlprTileSource({
for (const source of sources) source.clear();
precision.clear();
},
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
}
return {
fetch: fetchAlprNodes,
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
+35 -8
View File
@@ -1,9 +1,9 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { t } from '../../i18n/index.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
@@ -15,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
: count;
}
/** The count as the panel shows it, in the UI language. */
function presentAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = formatNumber(summary.count);
return summary.complete === false || summary.truncated === true
? t('At least {count}', { count })
: count;
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** The cohort's reason, kept English in data, shown in the UI language. */
function presentAwarenessReason(summary = {}) {
return summary.reasonTemplate
? t(summary.reasonTemplate, summary.reasonValues)
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -71,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = formatAwarenessCount(summary);
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
@@ -79,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -96,7 +123,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
@@ -176,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function namedAreasHtml(names) {
return names?.length
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
}
@@ -184,7 +211,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
+26 -12
View File
@@ -1,4 +1,17 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
// The reason stays English for the voice analyst; the Contacts panel shows it
// in the UI language from `reasonTemplate`. Kept local so this module stays
// free of the i18n import.
const msg = (text) => text;
const fillTemplate = (template, values = {}) =>
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
const withReason = (summary, template, values) => ({
...summary,
reason: fillTemplate(template, values),
reasonTemplate: template,
reasonValues: values,
});
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
@@ -119,19 +132,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
// A subject window is its own bounded area: name it instead of the viewport.
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
const km = Math.round(coverage.radiusM / 1000);
return {
...summary,
reason: summary.count
? `mapped matches within ${km} km of the subject`
: `none mapped within ${km} km; not a complete 250 km survey`,
};
return withReason(
summary,
summary.count
? msg('mapped matches within {km} km of the subject')
: msg('none mapped within {km} km; not a complete 250 km survey'),
{ km },
);
}
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
return withReason(
summary,
summary.count
? msg('mapped matches from the loaded viewport')
: msg('viewport feed is not a complete 250 km survey'),
);
}
/**
+18 -18
View File
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-midtown-w',
cityId: 'nyc',
poiIndex: 1,
label: 'Midtown West @ 34th',
label: 'Midtown West @ 34th', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: -70,
headingDeg: 206,
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-wtc-n',
cityId: 'nyc',
poiIndex: 2,
label: 'WTC North Plaza',
label: 'WTC North Plaza', // i18n-ignore: camera name
offsetNorthM: 95,
offsetEastM: 34,
headingDeg: 164,
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-times-square-ne',
cityId: 'nyc',
poiIndex: 1,
label: 'Times Sq Northeast',
label: 'Times Sq Northeast', // i18n-ignore: camera name
offsetNorthM: 230,
offsetEastM: 120,
headingDeg: 218,
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
id: 'sf-market-5th',
cityId: 'sf',
poiIndex: 2,
label: 'Market & 5th',
label: 'Market & 5th', // i18n-ignore: camera name
offsetNorthM: -160,
offsetEastM: 80,
headingDeg: 320,
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
id: 'sf-financial-district',
cityId: 'sf',
poiIndex: 1,
label: 'SF Financial Core',
label: 'SF Financial Core', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: 52,
headingDeg: 205,
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-shibuya-scramble',
cityId: 'tokyo',
poiIndex: 4,
label: 'Shibuya Crossing',
label: 'Shibuya Crossing', // i18n-ignore: camera name
offsetNorthM: 180,
offsetEastM: 46,
headingDeg: 18,
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-ginza-core',
cityId: 'tokyo',
poiIndex: 0,
label: 'Ginza Core',
label: 'Ginza Core', // i18n-ignore: camera name
offsetNorthM: -180,
offsetEastM: 150,
headingDeg: 245,
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-asakusa-n',
cityId: 'tokyo',
poiIndex: 3,
label: 'Asakusa North Gate',
label: 'Asakusa North Gate', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: -65,
headingDeg: 192,
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
id: 'london-city-a1',
cityId: 'london',
poiIndex: 4,
label: 'City Cluster A1',
label: 'City Cluster A1', // i18n-ignore: camera name
offsetNorthM: 80,
offsetEastM: 65,
headingDeg: 220,
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
id: 'london-soho-core',
cityId: 'london',
poiIndex: 2,
label: 'Soho Core',
label: 'Soho Core', // i18n-ignore: camera name
offsetNorthM: 210,
offsetEastM: 120,
headingDeg: 206,
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
id: 'paris-rivoli',
cityId: 'paris',
poiIndex: 4,
label: 'Rue de Rivoli',
label: 'Rue de Rivoli', // i18n-ignore: camera name
offsetNorthM: 55,
offsetEastM: 85,
headingDeg: 248,
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
id: 'paris-champs-n',
cityId: 'paris',
poiIndex: 1,
label: 'Champs-Élysées North',
label: 'Champs-Élysées North', // i18n-ignore: camera name
offsetNorthM: 130,
offsetEastM: -38,
headingDeg: 175,
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
id: 'dc-mall-center',
cityId: 'dc',
poiIndex: 1,
label: 'National Mall Center',
label: 'National Mall Center', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: 20,
headingDeg: 258,
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
id: 'dc-pentagon-s',
cityId: 'dc',
poiIndex: 3,
label: 'Pentagon South',
label: 'Pentagon South', // i18n-ignore: camera name
offsetNorthM: -100,
offsetEastM: 92,
headingDeg: 14,
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-difc-loop',
cityId: 'dubai',
poiIndex: 4,
label: 'DIFC Loop',
label: 'DIFC Loop', // i18n-ignore: camera name
offsetNorthM: 92,
offsetEastM: -45,
headingDeg: 196,
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-downtown-east',
cityId: 'dubai',
poiIndex: 0,
label: 'Downtown East',
label: 'Downtown East', // i18n-ignore: camera name
offsetNorthM: -130,
offsetEastM: 190,
headingDeg: 322,
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
id: 'austin-congress-s',
cityId: 'austin',
poiIndex: 0,
label: 'Congress Southbound',
label: 'Congress Southbound', // i18n-ignore: camera name
offsetNorthM: -165,
offsetEastM: 40,
headingDeg: 12,
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
id: 'austin-downtown-west',
cityId: 'austin',
poiIndex: 1,
label: 'Downtown West',
label: 'Downtown West', // i18n-ignore: camera name
offsetNorthM: -120,
offsetEastM: -160,
headingDeg: 120,
+79 -36
View File
@@ -14,23 +14,32 @@ import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
// "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', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
);
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
PTC: msg('Potential tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
Object.hasOwn(CLASSIFICATION_NAMES, code)
? t(CLASSIFICATION_NAMES[code])
: code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
value === null ? t('Unavailable') : formatQuantity(value, unit);
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
@@ -316,41 +325,68 @@ export function createCyclonesLayer({
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
? t('Track and cone match this advisory')
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
? t('Track/cone awaiting advisory {number}', {
number: storm.advisoryNumber,
})
: t('Track/cone unavailable'));
const status =
error ||
(snapshot?.stale
? 'Cached advisory · stale source'
? t('Cached advisory · stale source')
: loading
? 'Loading advisories…'
? t('Loading advisories…')
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const stormCount = snapshot?.storms.length || 0;
const activeStorms = tn(
stormCount,
'{count} active storm',
'{count} active storms',
);
const position = storm
? t('Position as of {time}', { time: utc(storm.positionAt) })
: '';
const intensity = storm
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
wind: number(storm.windKt, 'kt'),
pressure: number(storm.pressureHpa, 'hPa'),
})
: '';
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
? t('{name} · {classification} · Advisory {number} · issued {time}', {
name: storm.name,
classification: classificationName(storm.classification),
number: storm.advisoryNumber,
time: utc(storm.issuedAt),
})
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
? t('No active NHC/CPHC systems')
: stormCount
? activeStorms
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
? t('Loading advisories…')
: t('Advisories unavailable');
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
label: t('Cyclones · NHC / CPHC'),
coverage: t('Atlantic · E/C Pacific'),
compact: stormCount
? storm
? t('{storms} · {name} selected', {
storms: activeStorms,
name: storm.name,
})
: activeStorms
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
label: t('Official advisory ↗'),
href: storm.advisoryUrl,
},
]
@@ -360,12 +396,12 @@ export function createCyclonesLayer({
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
text: position,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
text: intensity,
},
{ id: 'geometry', text: geometry, muted: true },
]
@@ -375,12 +411,12 @@ export function createCyclonesLayer({
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
@@ -388,15 +424,22 @@ export function createCyclonesLayer({
chips: [],
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
{
label: t('Advisory center / forecast track'),
color: '#7fe6ed',
},
{
label: t('Center-track uncertainty cone'),
color: '#7fe6ed44',
},
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
infoTitle: t(
'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.',
),
};
return controls;
},
+3 -1
View File
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
// 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/);
assert.equal(controls.list.items[1].params.focus, true);
+350 -53
View File
@@ -17,10 +17,9 @@
*/
import * as Cesium from 'cesium';
import {
formatRouteDistance,
formatRouteDuration,
} from '../../data/routeSteps.js';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -31,9 +30,9 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
/** Travel modes, keyed by the `/api/route` profile name. */
export const DIRECTIONS_MODES = Object.freeze({
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
});
export const DEFAULT_DIRECTIONS_MODE = 'car';
@@ -68,6 +67,270 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
/** How often the row repaints the highlighted step while FLY is running (ms). */
export const FLIGHT_PROGRESS_MS = 250;
// ---------------------------------------------------------------------------
// Display text: distances, durations and maneuvers in the page's language.
// The proxy's English `instruction` stays the machine value; the row and the
// cards phrase each step from its OSRM type/modifier/road instead.
// ---------------------------------------------------------------------------
/**
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
* in src/data/routeSteps.js); number and unit never break apart.
* @param {number} meters
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
}
/**
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
* @param {number} seconds
* @returns {string}
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
function directionWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'direction' });
case 'right':
return t('right', { context: 'direction' });
case 'slight left':
return t('slightly left', { context: 'direction' });
case 'slight right':
return t('slightly right', { context: 'direction' });
case 'sharp left':
return t('sharply left', { context: 'direction' });
case 'sharp right':
return t('sharply right', { context: 'direction' });
case 'straight':
return t('straight', { context: 'direction' });
case 'uturn':
return t('around', { context: 'direction' });
default:
return modifier;
}
}
/**
* The side to keep at a fork. English reuses the turn words; a language may
* need another form here ("держитесь левее", not "держитесь налево").
*/
function forkWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'fork direction' });
case 'right':
return t('right', { context: 'fork direction' });
case 'slight left':
return t('slightly left', { context: 'fork direction' });
case 'slight right':
return t('slightly right', { context: 'fork direction' });
case 'sharp left':
return t('sharply left', { context: 'fork direction' });
case 'sharp right':
return t('sharply right', { context: 'fork direction' });
case 'straight':
return t('straight', { context: 'fork direction' });
default:
return directionWord(modifier);
}
}
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
function laneWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'lane direction' });
case 'right':
return t('right', { context: 'lane direction' });
case 'slight left':
return t('slightly left', { context: 'lane direction' });
case 'slight right':
return t('slightly right', { context: 'lane direction' });
case 'sharp left':
return t('sharply left', { context: 'lane direction' });
case 'sharp right':
return t('sharply right', { context: 'lane direction' });
case 'straight':
return t('straight', { context: 'lane direction' });
default:
return directionWord(modifier);
}
}
/** "1st", "2nd", … for a roundabout exit. */
function exitOrdinal(exit) {
const number = formatNumber(exit);
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
case 'one':
return t('{number}st', { context: 'ordinal', number });
case 'two':
return t('{number}nd', { context: 'ordinal', number });
case 'few':
return t('{number}rd', { context: 'ordinal', number });
default:
return t('{number}th', { context: 'ordinal', number });
}
}
/**
* One maneuver in the page's language, from the step's OSRM type, modifier,
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
* word for word. A step without a type (an older payload) keeps the proxy's
* English instruction.
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
* @returns {string}
*/
export function directionsInstruction(step) {
const type =
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
if (!type)
return typeof step?.instruction === 'string' ? step.instruction : '';
const modifier =
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
const road = roadLabel(step);
const direction = directionWord(modifier);
const values = { road, direction };
switch (type) {
case 'depart':
return road ? t('Head out on {road}', values) : t('Head out');
case 'arrive':
if (modifier === 'left')
return t('Arrive at your destination, on the left');
if (modifier === 'right')
return t('Arrive at your destination, on the right');
return t('Arrive at your destination');
case 'turn':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (modifier === 'straight')
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
if (direction)
return road
? t('Turn {direction} onto {road}', values)
: t('Turn {direction}', values);
return road
? t('Turn onto {road}', values)
: t('Turn', { context: 'maneuver' });
case 'continue':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (direction && modifier !== 'straight')
return road
? t('Continue {direction} onto {road}', values)
: t('Continue {direction}', values);
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
case 'end of road':
if (direction)
return road
? t('At the end of the road, turn {direction} onto {road}', values)
: t('At the end of the road, turn {direction}', values);
return road
? t('At the end of the road, continue onto {road}', values)
: t('At the end of the road, continue');
case 'fork':
if (direction) {
const fork = { road, direction: forkWord(modifier) };
return road
? t('Keep {direction} at the fork onto {road}', fork)
: t('Keep {direction} at the fork', fork);
}
return road
? t('Continue at the fork onto {road}', values)
: t('Continue at the fork');
case 'merge':
if (direction)
return road
? t('Merge {direction} onto {road}', values)
: t('Merge {direction}', values);
return road
? t('Merge onto {road}', values)
: t('Merge', { context: 'maneuver' });
case 'on ramp':
if (direction)
return road
? t('Take the ramp on the {direction} onto {road}', values)
: t('Take the ramp on the {direction}', values);
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
case 'off ramp':
if (direction)
return road
? t('Take the exit on the {direction} onto {road}', values)
: t('Take the exit on the {direction}', values);
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
case 'roundabout':
case 'rotary':
if (Number.isInteger(step.exit) && step.exit >= 1) {
const exit = exitOrdinal(step.exit);
return road
? t('At the roundabout, take the {exit} exit onto {road}', {
exit,
road,
})
: t('At the roundabout, take the {exit} exit', { exit });
}
return road
? t('Enter the roundabout onto {road}', values)
: t('Enter the roundabout');
case 'roundabout turn':
if (direction)
return road
? t('At the roundabout, turn {direction} onto {road}', values)
: t('At the roundabout, turn {direction}', values);
return road
? t('At the roundabout, continue onto {road}', values)
: t('At the roundabout, continue');
case 'exit roundabout':
case 'exit rotary':
return road
? t('Exit the roundabout onto {road}', values)
: t('Exit the roundabout');
case 'use lane':
if (direction) {
const lane = { road, direction: laneWord(modifier) };
return road
? t('Use the {direction} lane onto {road}', lane)
: t('Use the {direction} lane', lane);
}
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
default:
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
}
// ---------------------------------------------------------------------------
// Pure helpers (exported for tests)
// ---------------------------------------------------------------------------
@@ -107,32 +370,35 @@ export function directionsRowControls(state) {
const routing = status === 'routing';
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
id: `mode-${id}`,
label: spec.chip,
label: t(spec.chip),
active: mode === id,
state: mode === id ? 'active' : 'idle',
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: t(spec.word).toLowerCase(),
}),
params: { mode: id },
}));
chips.push({
id: 'set-a',
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
active: armed === 'a',
state: armed === 'a' ? 'active' : 'idle',
title:
armed === 'a'
? 'Click a spot on the globe to place A (click again to cancel)'
: 'Then click the globe to place the start',
? t('Click a spot on the globe to place A (click again to cancel)')
: t('Then click the globe to place the start'),
params: { arm: armed === 'a' ? null : 'a' },
});
chips.push({
id: 'set-b',
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
active: armed === 'b',
state: armed === 'b' ? 'active' : 'idle',
title:
armed === 'b'
? 'Click a spot on the globe to place B (click again to cancel)'
: 'Then click the globe to place the destination',
? t('Click a spot on the globe to place B (click again to cancel)')
: t('Then click the globe to place the destination'),
params: { arm: armed === 'b' ? null : 'b' },
});
chips.push({
@@ -140,26 +406,28 @@ export function directionsRowControls(state) {
label: '⇄',
disabled: !(a && b) || routing,
state: 'idle',
title: 'Swap A and B',
title: t('Swap A and B'),
params: { swap: true },
});
const flying = Number.isInteger(flightStep);
chips.push({
id: 'fly',
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
disabled: !route || routing,
busy: routing || flying,
active: flying,
state: routing || flying ? 'loading' : 'idle',
title: route ? 'Fly the camera along the route' : 'Place A and B first',
title: route
? t('Fly the camera along the route')
: t('Place A and B first'),
params: { fly: true },
});
chips.push({
id: 'clear',
label: 'CLEAR',
label: t('CLEAR', { context: 'action' }),
disabled: !a && !b && !route,
state: 'idle',
title: 'Remove the route and both markers',
title: t('Remove the route and both markers'),
params: { clear: true },
});
return {
@@ -179,8 +447,8 @@ export function directionsRowControls(state) {
*/
export const POINTER_TOOL_EXITS = Object.freeze({
draw: Object.freeze({
name: 'Draw',
leave: 'press Escape twice to leave Draw',
name: msg('Draw'),
leave: msg('press Escape twice to leave Draw'),
}),
});
@@ -201,9 +469,16 @@ export function pointerBlockedMessage(owner, which) {
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
const endpoint = which === 'b' ? 'B' : 'A';
const known = POINTER_TOOL_EXITS[id];
const name = known?.name || id;
const leave = known?.leave || `turn ${name} off`;
return `${name} is active — ${leave}, then set ${endpoint}`;
if (!known)
return t('{name} is active — turn {name} off, then set {endpoint}', {
name: id,
endpoint,
});
return t('{name} is active — {leave}, then set {endpoint}', {
name: t(known.name),
leave: t(known.leave),
endpoint,
});
}
/**
@@ -272,8 +547,8 @@ export function directionsStepList({
const items = steps.map((step, index) => ({
id: `step-${index}`,
ordinal: index + 1,
lead: formatRouteDistance(step.distanceM) || '—',
text: step.instruction,
lead: routeDistanceText(step.distanceM) || '—',
text: directionsInstruction(step),
active: index === active,
current: Number.isInteger(flightStep) && index === flightStep,
params: { step: index },
@@ -285,13 +560,15 @@ export function directionsStepList({
id: 'step-truncated',
ordinal: items.length + 1,
lead: '',
text: `Only the first ${steps.length} turns of this route are shown`,
text: t('Only the first {count} turns of this route are shown', {
count: steps.length,
}),
active: false,
current: false,
disabled: true,
});
}
return { ariaLabel: 'Turn-by-turn directions', items };
return { ariaLabel: t('Turn-by-turn directions'), items };
}
/**
@@ -331,12 +608,13 @@ export function directionsStats(state) {
armed,
pointerBlocked,
} = state;
const source = 'OSM routing';
// English in layer state; the layer row translates it.
const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel
if (pointerBlocked) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: 'Another map tool is using clicks — close it, then SET A again',
error: t('Another map tool is using clicks — close it, then SET A again'),
status: 'empty',
source,
};
@@ -347,7 +625,7 @@ export function directionsStats(state) {
lastUpdate,
error: null,
loading: true,
loadingLabel: 'Routing…',
loadingLabel: t('Routing…'),
source,
};
}
@@ -355,7 +633,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || 'No route found',
error: error || t('No route found'),
status: 'empty',
source,
};
@@ -364,10 +642,17 @@ export function directionsStats(state) {
// set (not only while loading), so the route summary and the placement
// guidance ride on it; `coverage` carries the same text for stats readers.
if (route) {
const word = DIRECTIONS_MODES[mode]?.word || mode;
const summary =
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
const spec = DIRECTIONS_MODES[mode];
const word = spec ? t(spec.word) : mode;
const values = {
distance: routeDistanceText(route.distanceM),
duration: routeDurationText(route.durationS),
mode: word,
count: route.steps.length,
};
const summary = route.stepsTruncated
? t('{distance} · {duration} · {mode} · first {count} turns', values)
: t('{distance} · {duration} · {mode}', values);
return {
count: route.steps.length,
lastUpdate,
@@ -378,10 +663,13 @@ export function directionsStats(state) {
};
}
let coverage;
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
else if (a && !b) coverage = 'SET B, then click the globe';
else if (!a && b) coverage = 'SET A, then click the globe';
else coverage = 'SET A, then click the globe';
if (armed)
coverage = t('Click the globe to place {endpoint}', {
endpoint: armed.toUpperCase(),
});
else if (a && !b) coverage = t('SET B, then click the globe');
else if (!a && b) coverage = t('SET A, then click the globe');
else coverage = t('SET A, then click the globe');
return {
count: 0,
lastUpdate,
@@ -403,14 +691,23 @@ export function directionsStepCopy(steps, index) {
const step = steps[index];
const details = [];
const leg = [];
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
const position = { step: index + 1, total: steps.length };
details.push(
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
leg.length
? t('Step {step} of {total} · then {leg}', {
...position,
leg: leg.join(' · '),
})
: t('Step {step} of {total}', position),
);
const next = steps[index + 1];
if (next) details.push(`Then: ${next.instruction}`);
return { title: step.instruction, details };
if (next)
details.push(
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
);
return { title: directionsInstruction(step), details };
}
/**
@@ -819,10 +1116,10 @@ export function createDirectionsLayer({ services, source }) {
_status = 'error';
_error =
payload?.error === 'no route found'
? 'No route found between A and B'
? t('No route found between A and B')
: payload?.error
? `Routing failed: ${payload.error}`
: 'No route found between A and B';
? t('Routing failed: {error}', { error: payload.error })
: t('No route found between A and B');
return;
}
_route = route;
@@ -839,10 +1136,10 @@ export function createDirectionsLayer({ services, source }) {
// happened instead.
_error =
error?.name === 'AbortError'
? 'Routing timed out'
? t('Routing timed out')
: error?.name === 'TypeError'
? 'Routing unavailable — no response from the routing service'
: error?.message || 'Routing unavailable';
? t('Routing unavailable — no response from the routing service')
: error?.message || t('Routing unavailable');
} finally {
clearTimeout(timer);
if (_routeAbort === controller) _routeAbort = null;
@@ -1037,7 +1334,7 @@ export function createDirectionsLayer({ services, source }) {
[
{
type: 'route',
label: 'Directions',
label: t('Directions'),
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
},
],
+67 -10
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { formatNumber, t } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -332,11 +332,11 @@ export function createModel({
function buildSelectedFireCard(fire, nowMs) {
const meta = [
t('{value} conf', {
value: confidenceBucket(fire.confidence),
value: confidenceLabel(confidenceBucket(fire.confidence)),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(t('{value} ago', { value: age }));
}
const sat = satelliteShortName(fire.satellite);
@@ -351,10 +351,10 @@ export function createModel({
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
title: t('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) +
formatLatLonLabel(fire.lat, fire.lon) +
(fire.night ? ` · ${t('NIGHT')}` : ''),
],
selected: true,
@@ -374,9 +374,9 @@ export function createModel({
function buildFireCard(candidate, nowMs) {
const fire = candidate.fire;
const meta = [confidenceBucket(fire.confidence)];
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(age);
}
const sat = satelliteShortName(fire.satellite) || fire.sensor;
@@ -388,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
title: t('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -407,9 +407,9 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
if (age) parts.push(t('new {value}', { value: age }));
}
return {
@@ -483,6 +483,37 @@ export function createModel({
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
}
/** formatLatLon for a card: "8,389° ю. ш. 44,162° з. д." in Russian. */
function formatLatLonLabel(lat, lon) {
const amount = (value) =>
formatNumber(Math.abs(value), {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
const latPart =
lat >= 0
? t('{value}°N', { value: amount(lat) })
: t('{value}°S', { value: amount(lat) });
const lonPart =
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) {
@@ -503,6 +534,32 @@ export function createModel({
return t('{count}h ago', { count: Math.round(minutes / 60) });
}
/** A confidence bucket as a card shows it, in the UI language. */
function confidenceLabel(bucket) {
switch (bucket) {
case 'high':
return t('high', { context: 'fire confidence' });
case 'nominal':
return t('nominal', { context: 'fire confidence' });
case 'low':
return t('low', { context: 'fire confidence' });
default:
return bucket;
}
}
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
function formatAgeLabel(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return t('<1h', { context: 'age' });
if (hours < 48)
return t('{count}h', { context: 'age', count: Math.round(hours) });
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
function confidenceBucket(confidence) {
+6 -2
View File
@@ -18,7 +18,9 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -115,7 +117,9 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
};
},
+5 -2
View File
@@ -5,6 +5,7 @@ import {
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js';
import { t } from '../../i18n/index.js';
export function createSelection({
layerState,
@@ -80,7 +81,7 @@ export function createSelection({
requestWorldFocus({
kind: 'fire',
id: fireDetectionKey(fire),
label: 'FIRE',
label: t('FIRE'),
position: components.model.firePosition(fire),
});
}
@@ -192,7 +193,9 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
properties: {
+11 -3
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import {
@@ -752,8 +753,15 @@ export function createTracking({
// the tail number rather than raw hex.
const cs = parts.queries._contactLabel(icao24, info);
const altFt = Math.round((info.altitude || 0) * 3.28084);
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
// Flight levels are an aviation code; feet and knots read in the active
// language ("0 фт · 1 уз").
const quantity = (value, unit) =>
`${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
const fl =
altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('STALE')
+29
View File
@@ -0,0 +1,29 @@
import { t } from '../../i18n/index.js';
/**
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
* the UI language. The class itself stays an English identifier in data.
* @param {string} klass
* @returns {string}
*/
export function installationClassLabel(klass) {
const key = String(klass || 'installation');
switch (key) {
case 'airfield':
return t('AIRFIELD', { context: 'installation class' });
case 'naval_base':
return t('NAVAL BASE', { context: 'installation class' });
case 'range':
return t('RANGE', { context: 'installation class' });
case 'military_land':
return t('MILITARY LAND', { context: 'installation class' });
case 'base':
return t('BASE', { context: 'installation class' });
case 'places_candidate':
return t('PLACES CANDIDATE', { context: 'installation class' });
case 'installation':
return t('INSTALLATION', { context: 'installation class' });
default:
return key.replaceAll('_', ' ').toUpperCase();
}
}
+2 -3
View File
@@ -1,3 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
@@ -177,9 +178,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
marker.entry.selected = record.id === state.selectedId;
marker.entry.details = marker.entry.selected
? [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
]
: [];
+2 -3
View File
@@ -1,3 +1,4 @@
import { installationClassLabel } from './classLabel.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
@@ -273,9 +274,7 @@ export function createRendering({
entity.gevLabelModel = {
title: record.name || t('MAPPED INSTALLATION'),
details: [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
installationClassLabel(record.class),
...(record.memberNames || []).slice(0, 3),
],
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
+1 -1
View File
@@ -470,7 +470,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('SELECTED SPACE MISSION'),
t('Selected Space Mission'),
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
+7 -5
View File
@@ -10,6 +10,7 @@ import {
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
import { t } from '../../i18n/index.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js';
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
getRowControls() {
const bands = [
{ label: 'Not contained or unknown', color: containmentAccent(0) },
{ label: 'Under 50% contained', color: containmentAccent(1) },
{ label: '50–99% contained', color: containmentAccent(50) },
{ label: 'Fully contained', color: containmentAccent(100) },
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
{ label: t('Under 50% contained'), color: containmentAccent(1) },
{ label: t('50–99% contained'), color: containmentAccent(50) },
{ label: t('Fully contained'), color: containmentAccent(100) },
];
return {
chips: [],
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
).length,
...(index === 0
? {
blurb:
blurb: t(
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
),
}
: {}),
})),
+2 -2
View File
@@ -70,7 +70,7 @@ export function createCategories({
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ id: 'all', label: t('All') },
{ id: 'all', label: t('All', { context: 'radio tag' }) },
{ id: 'news', label: t('News') },
{ id: 'talk', label: t('Talk') },
{ id: 'weather', label: t('Weather / Emergency') },
@@ -89,7 +89,7 @@ export function createCategories({
for (const [genre, label] of MUSIC_GENRES) {
const id = `genre:${genre}`;
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
categories.push({ id, label });
categories.push({ id, label: t(label) });
}
}
categories.push({ id: 'other', label: t('Other') });
+24 -2
View File
@@ -1,8 +1,30 @@
import {
RADIO_CATEGORY_COLORS,
RADIO_CLUSTER_LABELS,
RADIO_MARKER_CATEGORY_ORDER,
} from './policy.js';
import { t } from '../../i18n/index.js';
/** Short uppercase category word on a cluster badge, in the page language. */
function radioClusterLabel(categoryId) {
switch (categoryId) {
case 'news':
return t('NEWS', { context: 'radio' });
case 'talk':
return t('TALK', { context: 'radio' });
case 'weather':
return t('WEATHER', { context: 'radio' });
case 'public-safety':
return t('SAFETY', { context: 'radio' });
case 'aviation-marine':
return t('AIR / SEA', { context: 'radio' });
case 'traffic-transit':
return t('TRANSIT', { context: 'radio' });
case 'music':
return t('MUSIC', { context: 'radio' });
default:
return t('OTHER', { context: 'radio' });
}
}
export function createModel({ state: layerState, services, parts, source }) {
/** Return the shared CSS color for a canonical or detected-genre category. */
@@ -18,7 +40,7 @@ export function createModel({ state: layerState, services, parts, source }) {
function radioClusterBadgeText(categoryId = 'other', count = 0) {
const normalized = String(categoryId || 'other');
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
const label = radioClusterLabel(canonical);
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
return `${stationCount} ${label}`;
}
+29 -36
View File
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
// Genre labels are marked for i18n extraction; categories.js translates them.
// Kept local so this module stays free of the i18n import.
const msg = (text) => text;
export const MUSIC_GENRES = Object.freeze([
['alternative', 'Alternative'],
['ambient', 'Ambient'],
['blues', 'Blues'],
['classical', 'Classical'],
['country', 'Country'],
['dance', 'Dance'],
['electronic', 'Electronic'],
['folk', 'Folk'],
['funk', 'Funk'],
['hip hop', 'Hip-Hop'],
['house', 'House'],
['indie', 'Indie'],
['jazz', 'Jazz'],
['latin', 'Latin'],
['metal', 'Metal'],
['oldies', 'Oldies'],
['pop', 'Pop'],
['punk', 'Punk'],
['r&b', 'R&B'],
['reggae', 'Reggae'],
['rock', 'Rock'],
['soul', 'Soul'],
['techno', 'Techno'],
['trance', 'Trance'],
['world', 'World'],
['alternative', msg('Alternative')],
['ambient', msg('Ambient')],
['blues', msg('Blues')],
['classical', msg('Classical')],
['country', msg('Country')],
['dance', msg('Dance')],
['electronic', msg('Electronic')],
['folk', msg('Folk')],
['funk', msg('Funk')],
['hip hop', msg('Hip-Hop')],
['house', msg('House')],
['indie', msg('Indie')],
['jazz', msg('Jazz')],
['latin', msg('Latin')],
['metal', msg('Metal')],
['oldies', msg('Oldies')],
['pop', msg('Pop')],
['punk', msg('Punk')],
['r&b', msg('R&B')],
['reggae', msg('Reggae')],
['rock', msg('Rock')],
['soul', msg('Soul')],
['techno', msg('Techno')],
['trance', msg('Trance')],
['world', msg('World')],
]);
export const CATEGORY_MATCHERS = Object.freeze({
@@ -127,17 +131,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
other: '#9aa7b3',
});
export const RADIO_CLUSTER_LABELS = Object.freeze({
news: 'NEWS',
talk: 'TALK',
weather: 'WEATHER',
'public-safety': 'SAFETY',
'aviation-marine': 'AIR / SEA',
'traffic-transit': 'TRANSIT',
music: 'MUSIC',
other: 'OTHER',
});
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
'news',
'public-safety',
+44
View File
@@ -0,0 +1,44 @@
/**
* Recent Imagery text in the page's language: days, metres and the focused
* day's readout. The model (`model.js`) stays English and i18n-free because AI
* tool services import it outside the browser; this module is page-only.
*/
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { dayTime } from './model.js';
const SHORT_DAY = Object.freeze({
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const LONG_DAY = Object.freeze({ ...SHORT_DAY, year: 'numeric' });
/** `Sep 18` / `18 сент.` for a `YYYY-MM-DD` day, in the page's language. */
export function displayDay(day, options = SHORT_DAY) {
const time = dayTime(day);
return time === null ? '' : formatDate(time, options);
}
/** `30 m` / `30 м`, number and unit kept together. */
export const displayMetres = (value) => formatQuantity(value, 'm');
/** The readout's words in the page's language; the model keeps them English. */
export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
// "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) => {
if (!cloud) return t('cloud unknown');
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max
? t('{percent}% scene cloud', { percent: min })
: t('{min}–{max}% scene cloud', { min, max });
},
});
+26 -15
View File
@@ -28,11 +28,12 @@ import {
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
utcDay,
validateBox,
viewTooLargeMessage,
} from './model.js';
import { msg, t } from '../../i18n/index.js';
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
/**
* Quiet time after the last arrow key before the focused day previews, so a
@@ -46,10 +47,11 @@ export const SPLIT_PUBLISH_MS = 400;
/** The three modes, in share-link order (`m` = index). */
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
const COMPARISON_IN_USE = 'Comparison in use by another scene';
const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
// Kept English in the snapshot (AI tools read it); the card translates them.
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
/** ZOOM IN's flight time, in seconds. */
const ZOOM_FIT_SECONDS = 1.2;
const DEFAULT_SPLIT = 0.5;
@@ -748,15 +750,21 @@ export function createRecentImageryLayer({
function notes() {
const out = [];
if (_truncated) out.push('Catalog truncated · a newer clear day may exist');
if (_truncated)
out.push(t('Catalog truncated · a newer clear day may exist'));
for (const error of _catalogErrors) {
const label = PRODUCTS[error.product]?.label || error.product;
out.push(`${label} catalog unavailable · ${error.message}`);
out.push(
t('{label} catalog unavailable · {detail}', {
label,
detail: error.message,
}),
);
}
const today = utcDay(now());
const viirsToday = candidateFor(`VIIRS:${today}`);
if (viirsToday?.availability === 'unknown' && _sources.viirs)
out.push('Daily overview for today may still be empty until the pass');
out.push(t('Daily overview for today may still be empty until the pass'));
return out;
}
@@ -767,7 +775,7 @@ export function createRecentImageryLayer({
key: key || null,
candidate: candidateFor(key),
label: parsed
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
: null,
sourceOff,
drapable: drapable(candidateFor(key)),
@@ -1227,7 +1235,7 @@ export function createRecentImageryLayer({
_alpha = 1;
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
}
_notice = 'Box and images cleared';
_notice = msg('Box and images cleared');
syncRender();
publish([...BOX_EDGES, 'a', 'b', 'split']);
notify();
@@ -1276,7 +1284,9 @@ export function createRecentImageryLayer({
candidates,
focusIndex,
focus,
readout: focus ? formatCandidateReadout(focus, now()) : '',
readout: focus
? formatCandidateReadout(focus, now(), READOUT_WORDS)
: '',
recommended: ranked.candidate
? { key: ranked.candidate.key, reason: ranked.reason }
: null,
@@ -1329,17 +1339,18 @@ export function createRecentImageryLayer({
chips: [
{
id: 'hls',
label: 'MORE DETAIL · 30 m',
label: t('MORE DETAIL · 30 m'),
active: _sources.hls,
title: 'Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days',
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
params: { hls: !_sources.hls },
},
{
id: 'viirs',
label: 'DAILY OVERVIEW · 250 m',
label: t('DAILY OVERVIEW · 250 m'),
active: _sources.viirs,
title:
title: t(
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
),
params: { viirs: !_sources.viirs },
},
],
+1 -1
View File
@@ -228,7 +228,7 @@ test('a valid box searches and previews the START HERE day alone in IMAGE mode',
assert.equal(snapshot.focus.key, S18, 'focus starts on it');
assert.equal(snapshot.preview.key, S18);
assert.equal(snapshot.preview.slot, 'a');
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30 m');
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30\u00a0m');
assert.equal(snapshot.candidates[1].preview, true);
const none = {
key: null,
+51 -21
View File
@@ -1,3 +1,7 @@
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
@@ -29,10 +33,10 @@ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
label: 'HLS S30', // i18n-ignore: product code
name: msg('Sentinel-2'),
source: 'hls',
sensor: 'Sentinel-2 via HLS',
sensor: msg('Sentinel-2 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -41,10 +45,10 @@ export const PRODUCTS = Object.freeze({
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
label: 'HLS L30', // i18n-ignore: product code
name: msg('Landsat 8/9'),
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
sensor: msg('Landsat 8/9 via HLS'),
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
label: 'VIIRS', // i18n-ignore: product code
name: msg('Daily overview'),
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
@@ -262,6 +266,11 @@ export function utcDay(value) {
: null;
}
/** UTC midnight of a `YYYY-MM-DD` day in epoch ms, for locale formatting; null otherwise. */
export function dayTime(day) {
return isValidDay(day) ? Date.parse(`${day}T00:00:00Z`) : null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
@@ -479,15 +488,14 @@ export function rankLatest(
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
/** Whole UTC days from `day` to the day of `now`; null when either is invalid. */
function daysBetween(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
return Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
@@ -497,18 +505,40 @@ function cloudLabel(cloud) {
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* The words of the readout. The page passes its own (translated, locale
* formatted) set; this module stays free of i18n because AI tool services
* import it outside the browser.
*/
const READOUT_WORDS = Object.freeze({
/** `Sep 18, 2026` for a valid `YYYY-MM-DD` day. */
date: (day) => {
const [y, m, d] = day.split('-').map(Number);
return `${MONTHS[m - 1]} ${d}, ${y}`;
},
/** Age in whole UTC days, 0 or less for today. */
daysAgo: (delta) =>
delta <= 0 ? 'today' : delta === 1 ? 'yesterday' : `${delta} days ago`,
sensor: (sensor) => sensor,
metres: (value) => `${value} m`,
overview: () => 'overview',
/** `{ min, max }` scene cloud in percent, or null. */
cloud: cloudLabel,
});
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @param {Partial<typeof READOUT_WORDS>} [words] the page's localized words
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
export function formatCandidateReadout(candidate, now = new Date(), words) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (!product || !isValidDay(candidate?.day)) return '';
const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
let when = say.date(candidate.day);
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
@@ -518,11 +548,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
const delta = daysBetween(candidate.day, now);
if (delta !== null) parts.push(say.daysAgo(delta));
parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
if (product.overview) parts.push(say.overview());
parts.push(say.cloud(candidate.cloud));
return parts.join(' · ');
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
id: String(ISS_NORAD),
position,
variant: 'label',
title: 'ISS',
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
accent: '#ff4444',
priority: 1000,
collisionGroup: 'ambient-label',
+3 -1
View File
@@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) {
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
noradId || group
? satelliteClassLabel(group, { isIss, localize: false })
: null,
group,
};
}
+4 -1
View File
@@ -239,7 +239,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
name,
operator: '',
noradId: String(noradId),
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
class: satelliteClassLabel(sat.group, {
isIss: noradId === ISS_NORAD,
localize: false,
}),
altitude:
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
},
+7 -2
View File
@@ -18,6 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
@@ -335,7 +336,9 @@ export function createStreetLevelLayer({
}
function unavailableReason(entry) {
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
return t('{name} needs the Google 3D map: choose it under MAP SOURCE', {
name: translateKnown(entry.def.name),
});
}
/** One imagery source at a time: lighting a provider darkens the others. */
@@ -410,7 +413,9 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
});
return stats.hint || '';
}
@@ -1,5 +1,6 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { getLocale } from '../../../../i18n/index.js';
import { createGoogleViewer } from './viewer.js';
import {
COLORS,
@@ -48,7 +49,7 @@ function settleWithin(promise, { ms, signal, loader }) {
*/
export function createGoogleProvider({
getApiKey,
loader = createMapsLoader({ getApiKey }),
loader = createMapsLoader({ getApiKey, getLanguage: getLocale }),
}) {
if (typeof getApiKey !== 'function')
throw new TypeError('Google Street View needs getApiKey');
@@ -9,10 +9,13 @@ const READY_CALLBACK = '__gevGoogleMapsReady';
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
* @param {{getApiKey: () => string|null|undefined, getLanguage?: () => string|null|undefined, document?: Document, global?: object}} options
* `getLanguage` names the UI language; Google's own controls (Terms,
* Report a problem) follow it. English adds nothing, as before.
*/
export function createMapsLoader({
getApiKey,
getLanguage,
document = globalThis.document,
global = globalThis,
}) {
@@ -53,12 +56,15 @@ export function createMapsLoader({
};
const script = document.createElement('script');
script.async = true;
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
const params = new URLSearchParams({
key,
v: 'weekly',
loading: 'async',
callback: READY_CALLBACK,
})}`;
});
const language = getLanguage?.();
if (language && language !== 'en') params.set('language', language);
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${params}`;
script.onerror = () => {
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
@@ -142,3 +142,19 @@ test('a retried load still reports a refusal once, and the page handler once', a
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
test('Google controls follow the UI language; English adds no parameter', async () => {
const language = (locale) => {
const page = fakePage();
const loader = createMapsLoader({
getApiKey: () => 'k-1',
getLanguage: () => locale,
...page,
});
void loader.importLibrary('streetView').catch(() => {});
return new URL(page.scripts[0].src).searchParams.get('language');
};
assert.equal(language('es'), 'es');
assert.equal(language('ru'), 'ru');
assert.equal(language('en'), null);
});
@@ -1,10 +1,11 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View';
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
export const GOOGLE_NAME = msg('Google Street View');
export const GOOGLE_LABEL = msg('GOOGLE STREET VIEW');
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
@@ -19,8 +20,9 @@ export const GOOGLE_CREDIT_HTML =
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
/** Shown when Google refuses the key (gm_authFailure). */
export const KEY_REJECTED_MESSAGE =
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
export const KEY_REJECTED_MESSAGE = msg(
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer',
);
/** Past these, a nearest lookup, an open or a details lookup has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
@@ -29,10 +31,11 @@ export const DETAILS_TIMEOUT_MS = 10_000;
/** Pause after a failed details lookup before a view event can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
export const NO_ANSWER_MESSAGE = msg(
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY',
);
export const GROUND_CLICK_HINT = 'click a street to open Street View';
export const GROUND_CLICK_HINT = msg('click a street to open Street View');
/** Deep link to a panorama on Google Maps (Maps URLs). */
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
@@ -3,6 +3,7 @@ import {
PANO_FOV_DEFAULT_DEG,
wrapDegrees,
} from './panorama.js';
import { t } from '../../../../i18n/index.js';
import {
FLAT_FULL_SIZE,
FLAT_VIEW_SIZE,
@@ -436,11 +437,11 @@ export function createKartaViewViewer({
root.setAttribute('role', 'group');
root.setAttribute(
'aria-label',
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
t('KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom'),
);
picture = doc.createElement('img');
picture.className = 'sl-kv-photo';
picture.alt = 'KartaView street-level photo';
picture.alt = t('KartaView street-level photo');
picture.draggable = false;
picture.hidden = true;
canvas = makeCanvas(doc);
+5 -1
View File
@@ -119,7 +119,11 @@ export function composeUIState({
provider.groundClick !== true && provider.highlightsSequence !== false,
)
)
legend.push({ key: 'selected', label: t('Selected'), color: COLORS.selected });
legend.push({
key: 'selected',
label: t('Selected'),
color: COLORS.selected,
});
return {
enabled,
groundClickReady: groundClickReady === true,
+1 -1
View File
@@ -35,7 +35,7 @@ export function createBundledCableSource({
return json;
}
return {
label: 'TeleGeography',
label: 'TeleGeography', // i18n-ignore: data provider name
async fetch(signal) {
const [cables, landingPoints] = await Promise.all([
read(cableUrl(), signal),
+16 -5
View File
@@ -1,8 +1,8 @@
import {
accentForVesselType,
normalizeVesselType,
displayVesselType,
} from '../../data/vesselLabels.js';
import { t } from '../../i18n/index.js';
import { formatNumber, t } from '../../i18n/index.js';
export function createCards({
vesselState,
@@ -130,7 +130,7 @@ export function createCards({
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
function vesselTypeShort(record) {
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
return displayVesselType(record.type).toUpperCase().slice(0, 14);
}
/**
@@ -160,11 +160,22 @@ export function createCards({
}
function formatSpeed(speed) {
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
return t('{speed}KT', {
speed:
speed === null
? '--'
: formatNumber(speed, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: false,
}),
});
}
function formatHeading(heading) {
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
});
}
function formatPositionTime(record) {
+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) {
+132 -53
View File
@@ -7,6 +7,8 @@ import {
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js';
const STOPS = [
[10, '#00ecec'],
@@ -29,7 +31,9 @@ const LIGHTNING_STOPS = [
['300+', '#00FF00'],
];
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
value
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
: t('Unavailable');
/** A per-application observation layer, using the existing layer lifecycle and
* row controls. History is transient: shared links always open latest imagery. */
@@ -227,12 +231,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;
@@ -338,7 +344,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;
}
@@ -368,7 +376,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 {
@@ -487,15 +495,23 @@ export function createWeatherLayer({
noFrame &&
shared?.mode === 'history' &&
shared.products.find((entry) => entry.id === id)?.selected === null
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
? maxGap() / 60_000 < 60
? t('No frame within {gap} min of {time}', {
gap: maxGap() / 60_000,
time: utc(shared.target),
})
: t('No frame within {gap} h of {time}', {
gap: maxGap() / 3600_000,
time: utc(shared.target),
})
: null;
const time = shownTime();
const relation =
shared?.mode === 'history' && time
? Date.parse(time) === Date.parse(shared.target)
? ' · synced'
? 'synced'
: Date.parse(time) < Date.parse(shared.target)
? ' · nearest'
? 'nearest'
: ''
: '';
const times = manifest?.times || [];
@@ -508,9 +524,16 @@ export function createWeatherLayer({
age === null
? ''
: age >= 60
? `${Math.floor(age / 60)}h ${age % 60}m ago`
: `${age}m ago`;
? t('{hours}h {minutes}m ago', {
hours: Math.floor(age / 60),
minutes: age % 60,
})
: 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 =
@@ -535,49 +558,94 @@ export function createWeatherLayer({
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
let detail = time
? t('{state} · {time} · {lag}', {
state: followLatest ? t('Observed') : t('History'),
time: utc(time),
lag,
})
: missing
? t('Observation unavailable')
: t('Waiting for observation');
if (relation === 'synced') detail = t('{detail} · synced', { detail });
else if (relation === 'nearest')
detail = t('{detail} · nearest', { detail });
const infoHeading = radar
? t('RADAR REFLECTIVITY · dBZ')
: lightning
? t('LIGHTNING DENSITY · 15 min accumulation')
: product === 'clouds'
? t('GLOBAL INFRARED · hourly')
: t('GOES INFRARED · ~5 min');
const infoTime = time
? `${t('{state}: {time}', {
state: followLatest ? t('Latest observation') : t('History'),
time: utc(time),
})}\n${[
lag,
current && !followLatest
? t('frame {index}/{total}', {
index: index + 1,
total: times.length,
})
: '',
loading ? t('loading') : '',
]
.filter(Boolean)
.join(' · ')}`
: [t('Observation: unavailable'), loading ? t('loading') : '']
.filter(Boolean)
.join(' · ');
const infoCoverage = radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery');
const infoMotion = motion?.matches
? clock
? t('Reduced motion · history playback unavailable')
: t('Reduced motion · manual history available')
: '';
const controls = {
readout: true,
summary: {
label: radar
? 'Rain radar · US'
? t('Rain radar · US')
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
? t('Lightning density · 15 min')
: t('Satellite clouds'),
coverage: radar
? 'CONUS'
: lightning
? 'Americas + Pacific'
? t('Americas + Pacific')
: product === 'clouds'
? 'Global · 60°S–60°N'
: 'North America',
? t('Global · 60°S–60°N')
: t('North America'),
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail: time
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
: missing
? 'Observation unavailable'
: 'Waiting for observation',
detail,
status:
missing ||
hostStatus ||
error ||
diagnostic?.error ||
shownHost ||
shownProblem ||
(observationDelayed()
? 'Source observations delayed'
? t('Source observations delayed')
: manifest?.stale
? 'Stale source'
? t('Stale source')
: loading
? 'Loading next frame…'
? t('Loading next frame…')
: outside
? 'Map center outside coverage'
? t('Map center outside coverage')
: null),
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
},
chips: [
...(satellite
? [
['clouds-regional', 'N. America'],
['clouds', 'Global'],
['clouds-regional', t('N. America')],
['clouds', t('Global')],
].map(([value, label]) => ({
id: value,
label,
@@ -585,43 +653,44 @@ export function createWeatherLayer({
params: { product: value },
title:
value === 'clouds'
? 'Hourly global mosaic; usually 2–3 hours delayed'
: 'GOES regional clouds; approximately 5-minute updates',
? t('Hourly global mosaic; usually 2–3 hours delayed')
: t('GOES regional clouds; approximately 5-minute updates'),
}))
: []),
...(satellite
? [
{
id: 'filtered',
label: 'Clouds only',
label: t('Clouds only'),
active: infrared === 'filtered',
params: { infrared: 'filtered' },
title:
title: t(
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
),
},
{
id: 'full',
label: 'Full',
label: t('Full'),
active: infrared === 'full',
params: { infrared: 'full' },
title: 'The complete infrared image at the chosen opacity',
title: t('The complete infrared image at the chosen opacity'),
},
]
: []),
...['light', 'strong'].map((value) => ({
id: `opacity-${value}`,
label: value === 'light' ? 'Soft' : 'Vivid',
label: value === 'light' ? t('Soft') : t('Vivid'),
active: opacity === value,
params: { opacity: value },
title: 'Image opacity; does not alter the observed values',
title: t('Image opacity; does not alter the observed values'),
})),
{
id: 'coverage',
label: radar
? 'View US radar'
? t('View US radar')
: lightning
? 'View Americas & Pacific'
: 'View coverage',
? t('View Americas & Pacific')
: t('View coverage'),
disabled: !manifest || !runNavigation,
params: { focus: true },
},
@@ -632,37 +701,47 @@ export function createWeatherLayer({
label: String(label),
color,
blurb: lightning
? `${label} strikes/km²/min ×10³ (15-minute density)`
: `${label} dBZ radar reflectivity`,
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
}))
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
? 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
? '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.'
? 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.',
)
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: '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.',
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
)
: t(
'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.',
),
};
controls.summary.settings = [
...(satellite
? [
{
id: 'region',
label: 'REGION',
label: t('REGION'),
chips: controls.chips.filter(({ params }) => params.product),
},
{
id: 'image',
label: 'IMAGE',
label: t('IMAGE'),
chips: controls.chips.filter(({ params }) => params.infrared),
},
]
: []),
{
id: 'opacity',
label: 'OPACITY',
label: t('OPACITY'),
chips: controls.chips.filter(({ params }) => params.opacity),
},
];
@@ -677,7 +756,7 @@ export function createWeatherLayer({
getStats() {
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? 'Observed' : 'History',
countLabel: isLatest() ? t('Observed') : t('History'),
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
loading,
error: error || rendering?.getDiagnostics().error || null,
+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();
+3 -3
View File
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
/** Fixed physical scales shared by imagery and the field legend. */
export const WIND_FIELDS = Object.freeze({
speed: Object.freeze({
label: 'Wind speed',
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'm/s',
min: 0,
max: 30,
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
}),
temperature: Object.freeze({
label: '2 m air temperature',
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
units: '°C',
min: -40,
max: 50,
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
],
}),
pressure: Object.freeze({
label: 'Mean sea-level pressure',
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'hPa',
min: 960,
max: 1050,
+56 -41
View File
@@ -11,6 +11,8 @@ import {
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { t, msg } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
/** Format a forecast timestamp explicitly in UTC. */
export function formatWindValidTime(value) {
@@ -172,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);
@@ -242,7 +244,7 @@ export function createWindLayer({
getRowControls() {
const historyStatus =
clock?.getState().mode === 'history'
? 'Forecast · does not follow history'
? t('Forecast · does not follow history')
: null;
const valid = formatWindValidTime(manifest?.cycle?.validIso);
const run = formatWindValidTime(manifest?.cycle?.runIso);
@@ -258,13 +260,16 @@ 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: 'Wind motion',
speed: 'Wind speed',
pressure: 'Sea-level pressure',
temperature: 'Air temperature · 2 m',
none: t('Wind motion'),
speed: t('Wind speed'),
pressure: t('Sea-level pressure'),
temperature: t('Air temperature · 2 m'),
}[overlay];
const kind = ['temperature', 'pressure'].includes(overlay)
? overlay
@@ -290,60 +295,64 @@ export function createWindLayer({
readout: true,
summary: {
label,
coverage: 'Global · 1° grid',
coverage: t('Global · 1° grid'),
validTime: manifest?.cycle?.validIso,
issuedTime: manifest?.cycle?.runIso,
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
detail: t('{model} forecast · {time}', {
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
time: valid || t('Unavailable'),
}),
status: loading
? 'Loading forecast'
? t('Loading forecast')
: preparing
? 'Preparing flow'
: error ||
? t('Preparing flow')
: (error && t(error)) ||
manifest?.reason ||
imageryError ||
(scalarMissing ? 'Selected field unavailable' : null) ||
(manifest?.stale ? 'Cached forecast · stale' : null),
(scalarMissing ? t('Selected field unavailable') : null) ||
(manifest?.stale ? t('Cached forecast · stale') : null),
units: legendUnit,
},
chips: [
...['gfs', 'ifs'].map((value) => ({
id: `model-${value}`,
label: value === 'ifs' ? 'ECMWF' : 'GFS',
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
active: model === value,
params: { model: value },
title:
value === 'ifs'
? 'ECMWF IFS surface forecast'
: 'NOAA GFS surface forecast',
? t('ECMWF IFS surface forecast')
: t('NOAA GFS surface forecast'),
})),
{
id: 'motion',
label: diagnostic?.reducedMotion
? 'Reduced motion'
? t('Reduced motion')
: paused
? 'Resume'
: 'Pause',
? t('Resume')
: t('Pause'),
active: motionPaused,
disabled: Boolean(diagnostic?.reducedMotion),
params: { paused: !paused },
title:
title: t(
'Pause visual flow; forecast time does not advance with animation',
),
},
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
id: `overlay-${value}`,
label: {
none: 'None',
speed: 'Speed',
pressure: 'Pressure',
temperature: 'Temperature',
none: t('None'),
speed: t('Speed'),
pressure: t('Pressure'),
temperature: t('Temperature'),
}[value],
active: overlay === value,
params: { overlay: value },
title: {
none: 'Wind trails with no field shading',
speed: 'How hard the surface wind is blowing',
pressure: 'Sea-level air pressure: broad highs and lows',
temperature: 'Air temperature two meters above the surface',
none: t('Wind trails with no field shading'),
speed: t('How hard the surface wind is blowing'),
pressure: t('Sea-level air pressure: broad highs and lows'),
temperature: t('Air temperature two meters above the surface'),
}[value],
})),
...(speedLegendVisible ||
@@ -353,18 +362,19 @@ export function createWindLayer({
: []
).map((value) => ({
id: `units-${value}`,
label: value,
label: unitLabel(value),
active: units === value,
params: { units: value },
title: 'Wind speed units',
title: t('Wind speed units'),
})),
{
id: 'read-wind',
label: 'Read wind at map center',
label: t('Read wind at map center'),
params: { inspect: true },
disabled: loading || !manifest?.u || manifest?.unavailable,
title:
title: t(
'Read the forecast at the center of the map without changing selection',
),
},
],
legend:
@@ -373,9 +383,14 @@ export function createWindLayer({
(overlay === 'none' && !speedLegendVisible)
? []
: legend,
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
infoTitle:
info: `${t('{model} forecast · {field} ({unit})', {
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${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.',
),
};
controls.summary.reading = reading;
controls.summary.result = reading ? windReadingResult(reading) : null;
@@ -385,12 +400,12 @@ export function createWindLayer({
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
});
controls.summary.settings = [
setting('model', 'MODEL', 'model-'),
setting('field', 'FIELD', 'overlay-'),
setting('model', t('MODEL'), 'model-'),
setting('field', t('FIELD'), 'overlay-'),
...(controls.chips.some(({ id }) => id.startsWith('units-'))
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
? [{ id: 'units', label: t('UNITS'), chips: windUnitChips(units) }]
: []),
setting('motion', 'MOTION', 'motion'),
setting('motion', t('MOTION'), 'motion'),
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'read-wind',
@@ -425,7 +440,7 @@ export function createWindLayer({
: null;
return {
...windStats(manifest),
countLabel: 'Forecast',
countLabel: t('Forecast'),
loading: loading || Boolean(preparing),
model: model.toUpperCase(),
overlay,
@@ -433,7 +448,7 @@ export function createWindLayer({
stale: Boolean(manifest?.stale),
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
error:
error ||
windStats(manifest).error ||
+29 -23
View File
@@ -1,23 +1,27 @@
import { sampleWind, windSpeed } from './model.js';
import { sampleScalar } from './fields.js';
// The reading stays English: AI tools read it too. Text is marked for i18n
// extraction and translated where the WEATHER card shows it (presentation.js).
const msg = (text) => text;
const DIRECTIONS = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
msg('N'),
msg('NNE'),
msg('NE'),
msg('ENE'),
msg('E'),
msg('ESE'),
msg('SE'),
msg('SSE'),
msg('S'),
msg('SSW'),
msg('SW'),
msg('WSW'),
msg('W'),
msg('WNW'),
msg('NW'),
msg('NNW'),
];
export const WIND_UNITS = Object.freeze({
'km/h': 3.6,
@@ -56,13 +60,14 @@ export function inspectWindAtCenter(
));
if (!point || !snapshot?.u || !snapshot?.v)
return {
coordinates: 'No surface reading',
wind: 'Aim the map center at Earth',
coordinates: msg('No surface reading'),
wind: msg('Aim the map center at Earth'),
model,
validTime,
status,
explanation:
explanation: msg(
'A location reading needs a loaded forecast and the Earth at the center of the view.',
),
};
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
const lon = cesium.Math.toDegrees(location.longitude);
@@ -87,11 +92,11 @@ export function inspectWindAtCenter(
scalarKind: overlay,
scalarLabel:
overlay === 'speed'
? 'Wind speed'
? msg('Wind speed')
: overlay === 'temperature'
? 'Air temperature · 2 m'
? msg('Air temperature · 2 m')
: overlay === 'pressure'
? 'Sea-level pressure'
? msg('Sea-level pressure')
: null,
scalarValue:
overlay === 'speed'
@@ -102,8 +107,9 @@ export function inspectWindAtCenter(
model,
validTime,
status,
explanation:
explanation: msg(
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
),
};
}
+84 -7
View File
@@ -1,4 +1,6 @@
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
/** Reformat a captured sample; changing units never samples the map again. */
export function formatWindReading(reading, units) {
@@ -19,36 +21,111 @@ export function formatWindReading(reading, units) {
export function windUnitChips(units) {
return Object.keys(WIND_UNITS).map((value) => ({
id: `units-${value}`,
label: value,
label: unitLabel(value),
active: units === value,
params: { units: value },
title: 'Wind speed units',
title: t('Wind speed units'),
}));
}
/** formatWindSpeed's value in the active language: "22,0 км/ч". */
function displaySpeed(speed, units) {
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
return formatQuantity(speed * WIND_UNITS[unit], unit, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
/** "1012.4 hPa" / "-3.0 °C" from the English reading, in the active language. */
function displayScalar(reading) {
if (reading.scalarKind === 'speed' && Number.isFinite(reading.speed))
return displaySpeed(reading.speed, reading.units);
const match = /^(-?\d+(?:\.\d+)?) (°C|hPa)$/.exec(reading.scalarValue);
if (!match) return reading.scalarValue;
const decimals = match[1].split('.')[1]?.length || 0;
return formatQuantity(Number(match[1]), match[2], {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
/** The wind line in the active language; the reading itself stays English. */
function windText(reading) {
if (!Number.isFinite(reading.speed))
return reading.wind === 'Aim the map center at Earth'
? t('Aim the map center at Earth')
: reading.wind;
const speed = displaySpeed(reading.speed, reading.units);
return reading.from === 'Calm'
? `${speed}${t(' · calm')}`
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
}
/** "29.93°N · 97.74°W" from the English reading, in the active language. */
function displayCoordinates(coordinates) {
const match = /^(\d+(?:\.\d+)?)°([NS]) · (\d+(?:\.\d+)?)°([EW])$/.exec(
coordinates,
);
if (!match) return coordinates.replace(' · ', ' ');
const part = (value, hemisphere) => {
const decimals = value.split('.')[1]?.length || 0;
const amount = formatNumber(Number(value), {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: false,
});
switch (hemisphere) {
case 'N':
return t('{value}°N', { value: amount });
case 'S':
return t('{value}°S', { value: amount });
case 'E':
return t('{value}°E', { value: amount });
default:
return t('{value}°W', { value: amount });
}
};
return `${part(match[1], match[2])} ${part(match[3], match[4])}`;
}
/** "2026-10-14 14:00 UTC" from the reading, as "10-14 14:00 UTC" / "14.10 …". */
function displayValidTime(validTime) {
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) UTC$/.exec(validTime || '');
if (!match) return validTime?.replace(/^\d{4}-/, '');
return `${formatUtcDayMonth(match[1])} ${match[2]} UTC`;
}
/** Portable result block consumed by the WEATHER card. */
export function windReadingResult(reading) {
const coordinates =
reading.coordinates === 'No surface reading'
? t('No surface reading')
: displayCoordinates(reading.coordinates);
return {
id: 'reading',
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
label: t('WIND AT {coordinates}', { coordinates }),
lines: [
{
id: 'wind',
text: reading.wind,
text: windText(reading),
},
{
id: 'meta',
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
text: t('{model} · valid {time}', {
model: reading.model,
time: displayValidTime(reading.validTime),
}),
},
...(reading.scalarValue
? [
{
id: 'scalar',
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
text: `${t(reading.scalarLabel)} · ${displayScalar(reading)}`,
},
]
: []),
{ id: 'explanation', text: reading.explanation },
{ id: 'explanation', text: t(reading.explanation) },
],
clear: { params: { inspect: false } },
};
+33 -6
View File
@@ -2,7 +2,16 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { formatWindReading, windReadingResult } from './presentation.js';
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
const sample = {
speed: 5,
from: 'SW',
coordinates: '41.9°N · 87.6°W',
model: 'NOAA GFS',
validTime: '2026-09-21 12:00 UTC',
scalarLabel: 'Air temperature · 2 m',
scalarValue: '12 °C',
explanation: 'Interpolated model forecast.',
};
test('captured reading reformats without changing its sample and produces a portable result block', () => {
const reading = formatWindReading(sample, 'mph');
assert.equal(reading.wind, '11.2 mph from SW');
@@ -11,12 +20,30 @@ test('captured reading reformats without changing its sample and produces a port
assert.equal(sample.units, undefined);
const section = windReadingResult(reading);
assert.equal(section.id, 'reading');
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
assert.equal(
section.lines.find(({ id }) => id === 'scalar').text,
'Air temperature · 2 m · 12\u00a0°C',
);
assert.equal(
section.lines.find(({ id }) => id === 'wind').text,
'11.2\u00a0mph from SW',
);
assert.equal(
section.lines.find(({ id }) => id === 'meta').text,
'NOAA GFS · valid 09-21 12:00 UTC',
);
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
assert.deepEqual(section.clear.params, { inspect: false });
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
assert.equal(
formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind,
'5.0 m/s · calm',
);
assert.equal(formatWindReading(null, 'km/h'), null);
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
assert.equal(
windReadingResult({
coordinates: 'No surface reading',
wind: 'Unavailable',
}).lines.some(({ id }) => id === 'scalar'),
false,
);
});
+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');
}
}
+4
View File
@@ -244,6 +244,7 @@ export function reduceTrafficSyncFeedback(
// Neutral default: the layer always supplies its own LIVE/SIMULATED
// label, and a fallback string must never claim a live feed on a
// keyless build.
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
label: label || 'syncing road network',
progressText: hasProgress ? `${progressPct}%` : '...',
};
@@ -392,6 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
);
return {
state: 'retry',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
label: (
camera.cameraRetry.error || 'Overpass temporarily unavailable'
).toUpperCase(),
@@ -438,6 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
return {
state: 'loading',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
label: active[0].cameraRetry.retrying
? 'RETRYING ALPR CAMERAS'
: 'FETCHING ALPR CAMERAS',
@@ -452,6 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
) {
return {
state: 'loading',
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
label: active[0].installationRetry.retrying
? 'RETRYING MAPPED SITES'
: 'FETCHING MAPPED SITES',
+1 -1
View File
@@ -26,7 +26,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else
assert.equal(writes.length, 1, 'the style indicator must have exactly one writer');
assert.match(
ui.slice(writes[0].index, writes[0].index + 160),
/this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/,
/this\._styleIndicator\.textContent = STYLE_STATUS_LABELS\[styleName\]\s*\?\s*t\(STYLE_STATUS_LABELS\[styleName\]\)\s*:\s*styleName\.toUpperCase\(\);/,
);
const handler = locationSearchHandler();
+4 -1
View File
@@ -1,5 +1,6 @@
import { createStandaloneApplication } from './standalone/application.js';
import { describeError } from './standalone/errors.js';
import { t } from './i18n/index.js';
const application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
@@ -10,7 +11,9 @@ const application = createStandaloneApplication({
application.start().catch((error) => {
console.error("God's Eye View initialization failed:", error);
const loaderStatus = document.querySelector('#loading-screen .loader-status');
loaderStatus.textContent = `Error: ${describeError(error)}`;
loaderStatus.textContent = t('Error: {message}', {
message: describeError(error),
});
loaderStatus.style.color = '#ff4444';
});
+1 -1
View File
@@ -495,7 +495,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
);
assert.match(
html,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><\/div>/,
);
assert.doesNotMatch(
html,
+7 -2
View File
@@ -14,7 +14,12 @@ import { t } from '../i18n/index.js';
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
return t(
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network",
);
const envVars = keySetupRequirementEnvVars('google-maps');
return `${t('Needs {envVars} — add it in Provider Settings', { envVars })} — or a Cesium ion token for the ion-hosted route`;
return t(
'Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route',
{ envVars },
);
}
+5 -5
View File
@@ -2,14 +2,14 @@ import * as Cesium from 'cesium';
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
label: 'Google 3D', // i18n-ignore: imagery product name
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
label: 'Bing Aerial', // i18n-ignore: imagery product name
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
@@ -17,7 +17,7 @@ export const MAP_STACKS = [
},
{
id: 'bing-labels',
label: 'Bing Labels',
label: 'Bing Labels', // i18n-ignore: imagery product name
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
@@ -25,14 +25,14 @@ export const MAP_STACKS = [
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
label: 'Esri Satellite', // i18n-ignore: imagery product name
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
label: 'OSM', // i18n-ignore: imagery product name
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
+7 -1
View File
@@ -6,7 +6,13 @@
*/
/** Guidance shown when the active map stack cannot host draped imagery. */
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
// Marks text for i18n extraction without importing i18n here; the UI
// translates it where it is shown.
const msg = (text) => text;
export const NO_IMAGERY_HOST = msg(
'Hidden by this map source · choose a globe map',
);
/**
* Resolve the imagery layer collection for the active map stack.
+5 -2
View File
@@ -21,6 +21,7 @@ import {
placementVariants,
} from './worldOverlayDraw.js';
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
import { t } from '../i18n/index.js';
/**
* @module worldOverlay
@@ -1269,7 +1270,7 @@ function ensureOverlayDom() {
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
_accessibilityRoot.className = 'world-overlay-accessibility';
_accessibilityRoot.setAttribute('role', 'region');
_accessibilityRoot.setAttribute('aria-label', 'Visible map targets');
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
document.body.appendChild(_accessibilityRoot);
}
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
@@ -2419,7 +2420,9 @@ function syncAccessibleActions() {
if (!activate) return;
const accepted = activate();
if (accepted !== false && _accessibilityStatus) {
_accessibilityStatus.textContent = `Focusing ${item.label}`;
_accessibilityStatus.textContent = t('Focusing {label}', {
label: item.label,
});
}
});
_accessibilityList.appendChild(button);
@@ -351,6 +351,7 @@ function buildWorkload(count) {
id: `mover-${index}`,
position: () => position,
variant: 'label',
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: `TRK ${1000 + index}`,
details: [`ALT ${10_000 + index * 25}`],
priority: index % 7,
@@ -461,6 +462,7 @@ function buildPhase3FirmsWorkload(count) {
position,
gapPx: 10,
accent: '224, 82, 82',
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: `▲ ${50 + fireIndex} MW`,
details: ['high · 2h · N20'],
selected: false,
@@ -551,6 +553,7 @@ function buildPhase3VesselsWorkload(count) {
position,
gapPx: 10,
accent: '224, 82, 82',
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: `▲ ${50 + fireIndex} MW`,
details: ['high · 2h · N20'],
selected: false,
@@ -570,6 +573,7 @@ function buildPhase3VesselsWorkload(count) {
position,
gapPx: selected ? 12 : 10,
accent: '57, 213, 255',
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
details: selected
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
@@ -644,6 +648,7 @@ function buildPhase3TrackedWorkload(count) {
gevTrackedId: 'flights:allocation-probe',
gevDisplayPosition: selected.position,
gevLabelModel: {
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: 'ALLOC01',
details: ['FL350 · 451 kts', 'TEST AIR · A320'],
accent: '#39d0ff',
@@ -694,6 +699,7 @@ function buildPhase4CctvWorkload(count) {
const entry = createCctvThumbnailOverlayEntry({
id: active ? 'cctv:active' : `cctv:${index}`,
position,
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`,
frameSlot: slot,
rank: index,
@@ -867,6 +873,7 @@ function buildPhase5CivilWorkload(count) {
const entry = registration?.entries[0];
if (!entry)
throw new Error('phase5-civil requires the protected tracked entry');
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
entry.title = 'ALLOC01 · FL350 · 451 kts';
entry.details = ['TEST AIR · A320', 'AUS → LAX'];
return workload;
@@ -881,6 +888,7 @@ function buildPhase5MilitaryWorkload(count) {
if (!entry)
throw new Error('phase5-military requires the protected tracked entry');
entry.id = 'military:allocation-probe';
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
entry.title = 'RCH451';
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
entry.accent = '#ffd166';
@@ -1067,6 +1075,7 @@ function appendSubmarineCableWorkload(workload, count) {
id: `${kind}-reference-${index}-alloc`,
kind,
label:
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
tip: position,
distanceM: 200_000 + index * 40_000,
+2 -1
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { renderPackGeometry } from './geometry.js';
import { t } from '../../i18n/index.js';
function caption(viewer, pack) {
const card = document.createElement('section');
@@ -18,7 +19,7 @@ function caption(viewer, pack) {
card.append(title);
if (pack.attribution.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.textContent = t('Source');
link.href = pack.attribution.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
+66 -45
View File
@@ -23,10 +23,12 @@ import * as Cesium from 'cesium';
import {
SCENE_APPEND_RECIPES,
SCENE_RECIPES,
displaySceneTitle,
displayShotTitle,
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { t, msg } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js';
import { createSceneSharing } from './sharing.js';
@@ -66,26 +68,7 @@ import {
// project titles, and voice-matching titles all stay English (keep-English
// boundary + persisted data); only the presentation of an UNRENAMED built-in
// localizes, so an operator's renamed scene is never masked by the catalog.
const RECIPE_TITLE_KEYS = Object.freeze({
'flights-radar': msg('Global Flights Radar'),
'orbital-watch': msg('Orbital Watch'),
'thermal-threats': msg('Thermal Threat Board'),
'city-overload': msg('City Overload'),
'omniscience-pullback': msg('Omniscience Pullback'),
});
/**
* Display title for a scene: the localized recipe name while the stored title
* still matches the built-in verbatim, the stored title otherwise.
* @param {{id?: string, title?: string}} scene
* @returns {string}
*/
function displaySceneTitle(scene) {
const key = RECIPE_TITLE_KEYS[scene?.id];
if (!key) return scene?.title || '';
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene.id);
return scene.title === builtin?.title ? t(key) : scene.title;
}
// Recipe and built-in shot titles are marked with msg() in recipes.js.
/** @constant {string} localStorage key for the serialized project */
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
@@ -397,8 +380,8 @@ export class SceneDirector {
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded')
this._presentation.status = t('Loaded: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
});
this._publish({
type,
@@ -419,10 +402,15 @@ export class SceneDirector {
subscribe: (listener) => this.subscribe(listener),
read: () => ({
// Scene titles surface in the panel through SceneControls; built-in
// recipe titles display their localized name until renamed by the user.
// recipe and shot titles display their localized name until renamed
// by the user. Shots keep the stored title for editing and matching.
scenes: this._project.scenes.map((scene) => ({
...scene,
title: displaySceneTitle(scene),
shots: scene.shots.map((shot) => ({
...shot,
displayTitle: displayShotTitle(shot.title),
})),
})),
selectedSceneId: this._selectedSceneId,
selectedShotId: this._selectedShotId,
@@ -519,6 +507,7 @@ export class SceneDirector {
const scene = {
id: uid('scene'),
// i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it.
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
shots: [],
};
@@ -797,7 +786,9 @@ export class SceneDirector {
));
if (!inventoryMatches) {
this._updateStatus(
`Cannot update ${recipe.title}: shot inventory changed`,
t('Cannot update {scene}: shot inventory changed', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -828,7 +819,9 @@ export class SceneDirector {
);
if (!sourcePackMatches) {
this._updateStatus(
`Cannot update ${recipe.title}: evidence beats changed`,
t('Cannot update {scene}: evidence beats changed', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -851,7 +844,9 @@ export class SceneDirector {
}));
if (resolvedPatches.some(({ shot }) => !shot)) {
this._updateStatus(
`Cannot update ${recipe.title}: shot bindings are incomplete`,
t('Cannot update {scene}: shot bindings are incomplete', {
scene: t(recipe.title),
}),
);
return {
appended: false,
@@ -931,8 +926,18 @@ export class SceneDirector {
if (announce) {
this._updateStatus(
marker
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
: `Appended ${appendedShots.length} shots: ${recipe.title}`,
? tn(
patchedShotCount,
'Updated {count} shot: {scene}',
'Updated {count} shots: {scene}',
{ scene: t(recipe.title) },
)
: tn(
appendedShots.length,
'Appended {count} shot: {scene}',
'Appended {count} shots: {scene}',
{ scene: t(recipe.title) },
),
);
}
return {
@@ -961,9 +966,8 @@ export class SceneDirector {
const shot = normalizeShot(
{
id: uid('shot'),
title: t('Shot {n}', {
n: scene.shots.length + 1,
}),
// i18n-ignore: stored English; displayShotTitle translates it when shown.
title: `Shot ${scene.shots.length + 1}`,
durationSec: DEFAULT_SHOT_DURATION_SEC,
holdSec: DEFAULT_HOLD_SEC,
camera,
@@ -979,8 +983,8 @@ export class SceneDirector {
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
}),
);
}
@@ -1012,8 +1016,8 @@ export class SceneDirector {
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
}),
);
}
@@ -1121,7 +1125,11 @@ export class SceneDirector {
const released = await this._releaseSceneLayers(previousScene, token);
if (!released || token.cancelled) {
if (!token.cancelled)
this._updateStatus(`Could not leave scene: ${previousScene.title}`);
this._updateStatus(
t('Could not leave scene: {scene}', {
scene: displaySceneTitle(previousScene),
}),
);
return;
}
this._loadedSceneId = null;
@@ -1171,7 +1179,12 @@ export class SceneDirector {
seeking: true,
});
if (this._loadAbort === controller) this._loadAbort = null;
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._updateStatus(
t('Seeked: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
}),
);
this._activateInteractions(scene, shot);
return { started: true, shotId };
}
@@ -1533,7 +1546,12 @@ export class SceneDirector {
running: false,
seeking: true,
});
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
this._updateStatus(
t('Seeked: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
}),
);
return true;
}
@@ -1807,6 +1825,7 @@ export class SceneDirector {
// Initialize telemetry accumulator for this run
this._activeRun = {
recipeId: `project-${PROJECT_VERSION}`,
// i18n-ignore: run telemetry record, downloaded as JSON
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
@@ -1950,7 +1969,7 @@ export class SceneDirector {
if (action.type === 'shot') {
if (this._interactionTransitions >= 64) {
this._updateStatus(
'Scene transition limit reached — load a shot to reset',
t('Scene transition limit reached — load a shot to reset'),
);
return false;
}
@@ -1993,7 +2012,7 @@ export class SceneDirector {
try {
payload = stringifySceneDocument(this._project);
} catch (error) {
this._updateStatus(`Export failed: ${error.message}`);
this._updateStatus(t('Export failed: {error}', { error: error.message }));
return;
}
// Trigger a browser download via a temporary anchor element
@@ -2066,7 +2085,7 @@ export class SceneDirector {
this._loadedSceneId = null;
this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(`Imported ${file.name}`);
this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
@@ -2077,8 +2096,8 @@ export class SceneDirector {
return false;
this._updateStatus(
error instanceof SceneDocumentError
? `Import failed: ${error.message}`
: 'Import failed (could not read JSON file)',
? t('Import failed: {error}', { error: error.message })
: t('Import failed (could not read JSON file)'),
);
}
}
@@ -2178,7 +2197,9 @@ export class SceneDirector {
}
if (refused.length) {
this._updateStatus(`Layers refused: ${refused.join(', ')}`);
this._updateStatus(
t('Layers refused: {layers}', { layers: refused.join(', ') }),
);
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
}
return { applied, refused, cancelled: false };
+15 -8
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
import { createInteractionSession } from '../director/interactions/session.js';
import { t } from '../i18n/index.js';
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
export function createSceneInteractions(
@@ -24,7 +25,7 @@ export function createSceneInteractions(
card.append(text);
if (item.action.url) {
const link = document.createElement('a');
link.textContent = 'Source';
link.textContent = t('Source');
link.style.color = '#6eeaff';
link.href = item.action.url;
link.target = '_blank';
@@ -60,8 +61,8 @@ export function createSceneInteractions(
const ok = await session.dispatch(id);
if (current && current === panel && status)
status.textContent = ok
? 'Action complete'
: 'Action unavailable or cancelled';
? t('Action complete')
: t('Action unavailable or cancelled');
return ok;
}
return {
@@ -87,7 +88,7 @@ export function createSceneInteractions(
}
panel = document.createElement('section');
panel.dataset.directorInteractions = '';
panel.setAttribute('aria-label', 'Scene actions');
panel.setAttribute('aria-label', t('Scene actions'));
Object.assign(panel.style, {
position: 'absolute',
left: '12px',
@@ -104,12 +105,13 @@ export function createSceneInteractions(
});
const owner = panel;
const title = document.createElement('strong');
title.textContent = 'Scene actions';
title.textContent = t('Scene actions');
panel.append(title);
status = document.createElement('p');
status.setAttribute('role', 'status');
status.textContent =
'Select a feature or use Tab and Enter to choose an action.';
status.textContent = t(
'Select a feature or use Tab and Enter to choose an action.',
);
panel.append(status);
for (const item of items) {
const button = document.createElement('button');
@@ -157,7 +159,12 @@ export function createSceneInteractions(
? '2px solid #6eeaff'
: '';
}
status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
status.textContent = t(
'Selected feature: {feature}. Choose an action.',
{
feature: matches[0].target.featureId,
},
);
buttons.get(matches[0].id)?.focus({ preventScroll: true });
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
},
+12
View File
@@ -7,6 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
const ADDITIONAL_SHOTS = [
{
id: 'second-landslide',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Second landslide',
lat: 28.399234,
lon: 85.535806,
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dhunche',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dhunche',
lat: 28.161431,
lon: 85.324278,
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-upper-trishuli',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Mailung / Upper Trishuli bridge',
lat: 28.140642,
lon: 85.265996,
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'mailung-bazzar',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Mailung Bazzar, Dandaguan',
lat: 28.126526,
lon: 85.248027,
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dandagaun, Rasuwa',
lat: 28.124424,
lon: 85.245154,
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'dandagaun-viewpoint',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Dandagaun viewpoint restaurant',
lat: 28.086663,
lon: 85.229914,
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'betrawati-bazaar',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Betrawati Bazaar',
lat: 28.029827,
lon: 85.227694,
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'bhainse',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Bhainse',
lat: 28.023087,
lon: 85.229449,
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'devighat-taadi-khola-bridge',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Devighat / Taadi Khola Bridge',
lat: 27.921132,
lon: 85.177113,
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'charaudi',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Charaudi',
lat: 27.865095,
lon: 84.838424,
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
},
{
id: 'final-view',
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Final view',
lat: 28.0834,
lon: 85.206,
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
shotPatches: [
...(base.shotPatches || []),
{
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
title: 'Final view',
layers: finalView.layers,
},
+3 -2
View File
@@ -1,5 +1,6 @@
/** Connect the portable Director runner to the existing scene presentation. */
import { t } from '../i18n/index.js';
import { displaySceneTitle, displayShotTitle } from './recipes.js';
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
let timing;
@@ -19,8 +20,8 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
t('Running {index}/{total}: {scene} / {shot}', {
index: index + 1,
total,
scene: scene.title,
shot: shot.title,
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title),
}),
);
director._logEvent('shot_start', {
+4
View File
@@ -122,6 +122,7 @@ export function recipeToScene(recipe) {
const mapStack = keyframe.mapStack || post.mapStack;
return {
id: uid('shot'),
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
title: keyframe.title || `Shot ${idx + 1}`,
durationSec: Math.max(
0.2,
@@ -171,6 +172,7 @@ export function recipeToScene(recipe) {
return {
id: recipe.id || uid('scene'),
// i18n-ignore: persisted fallback; built-in titles display through director
title: recipe.title || 'Untitled Scene',
releaseLayerIds: [
...new Set(
@@ -225,6 +227,7 @@ export function normalizeShot(
return {
id: rawShot?.id || uid('shot'),
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
title: rawShot?.title || `Shot ${index + 1}`,
durationSec: Math.max(
0.2,
@@ -349,6 +352,7 @@ export function normalizeProject(rawProject) {
);
return {
id: scene?.id || uid('scene'),
// i18n-ignore: persisted fallback for a saved scene without a title
title: scene?.title || `Scene ${sceneIdx + 1}`,
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
+88 -7
View File
@@ -4,11 +4,12 @@
*/
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
import { msg, t } from '../i18n/index.js';
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
id: 'bhote-koshi-nepal-evidence-pack',
version: 12,
title: 'Bhote Koshi Evidence Sequence',
title: msg('Bhote Koshi Evidence Sequence'),
style: 'normal',
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
layers: {
@@ -26,6 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
cameraPath: [
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Global Incident Context',
lat: 27.5962,
lon: 78.5718,
@@ -43,6 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-Focused Globe Rotation',
lat: 28.7248,
lon: 81.3226,
@@ -60,6 +63,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Regional Approach',
lat: 27.0354,
lon: 84.6287,
@@ -77,6 +81,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Nearby Cities',
lat: 27.891,
lon: 85.428,
@@ -94,6 +99,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Incident Corridor',
lat: 28.0529,
lon: 85.2189,
@@ -111,6 +117,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Flood Path',
lat: 28.1691,
lon: 85.352,
@@ -128,6 +135,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Corridor Overview',
lat: 27.8986,
lon: 84.8723,
@@ -145,6 +153,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Upper Valley',
lat: 28.4187,
lon: 85.4341,
@@ -221,6 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
shotPatches: [
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Global Incident Context',
visual: { mapStack: 'photoreal' },
layers: {
@@ -236,6 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-Focused Globe Rotation',
visual: { mapStack: 'photoreal' },
layers: {
@@ -251,6 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Regional Approach',
visual: { mapStack: 'photoreal' },
layers: {
@@ -266,6 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Nearby Cities',
visual: { mapStack: 'photoreal' },
layers: {
@@ -281,6 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Incident Corridor',
camera: {
lat: 28.0529,
@@ -305,6 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Flood Path',
visual: { mapStack: 'photoreal' },
layers: {
@@ -320,6 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Corridor Overview',
visual: { mapStack: 'photoreal' },
layers: {
@@ -335,6 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bhote Koshi Upper Valley',
holdSec: 8,
visual: { mapStack: 'photoreal' },
@@ -357,6 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Upper Valley Collapse',
camera: {
lat: 28.417,
@@ -380,24 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-China Border Gate',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Timure Evidence Cluster',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Syabru Besi Passage',
visual: { mapStack: 'photoreal' },
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bidur / Trishuli Consequence',
visual: { mapStack: 'photoreal' },
},
],
cameraPath: [
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Upper Valley Collapse',
lat: 28.417,
lon: 85.3937,
@@ -421,6 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Debris-Dammed Lake',
lat: 28.392914,
lon: 85.570921,
@@ -444,6 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Nepal-China Border Gate',
mapStack: 'photoreal',
lat: 28.316789,
@@ -474,6 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Timure Evidence Cluster',
mapStack: 'photoreal',
lat: 28.285968,
@@ -502,6 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Syabru Besi Passage',
mapStack: 'photoreal',
lat: 28.210306,
@@ -530,6 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
},
},
{
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
title: 'Bidur / Trishuli Consequence',
mapStack: 'photoreal',
lat: 27.981737,
@@ -569,7 +597,7 @@ const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
? Object.freeze({
id: 'bhote-koshi-nepal-scene',
title: 'Nepal Flood Incident',
title: msg('Nepal Flood Incident'),
durationSec: 15,
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
@@ -583,6 +611,7 @@ const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
.slice(0, 3)
.map((keyframe, index) => ({
...keyframe,
// i18n-ignore: legacy shot titles the bootstrap matches verbatim
title: `Shot ${index + 1}`,
})),
})
@@ -593,7 +622,7 @@ const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
const PUBLIC_SCENE_RECIPES = [
{
id: 'flights-radar',
title: 'Global Flights Radar',
title: msg('Global Flights Radar'),
durationSec: 30,
style: 'retro',
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
@@ -663,7 +692,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'orbital-watch',
title: 'Orbital Watch',
title: msg('Orbital Watch'),
durationSec: 32,
style: 'surveillance',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -741,7 +770,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'thermal-threats',
title: 'Thermal Threat Board',
title: msg('Thermal Threat Board'),
durationSec: 26,
style: 'thermal',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -809,7 +838,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'city-overload',
title: 'City Overload',
title: msg('City Overload'),
durationSec: 30,
style: 'surveillance',
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
@@ -887,7 +916,7 @@ const PUBLIC_SCENE_RECIPES = [
},
{
id: 'omniscience-pullback',
title: 'Omniscience Pullback',
title: msg('Omniscience Pullback'),
durationSec: 36,
style: 'retro',
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
@@ -982,3 +1011,55 @@ export function getSceneRecipeById(id) {
export function getSceneAppendRecipeById(id) {
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
}
// Built-in shot titles stay English in recipes and saved projects (upgrades
// and bindings match them verbatim). These are their display names; a title
// the user typed shows as typed. Place names inside them stay Latin.
const BUILTIN_SHOT_TITLES = new Set([
msg('Global Incident Context'),
msg('Nepal-Focused Globe Rotation'),
msg('Bhote Koshi Regional Approach'),
msg('Bhote Koshi Nearby Cities'),
msg('Bhote Koshi Incident Corridor'),
msg('Bhote Koshi Flood Path'),
msg('Bhote Koshi Corridor Overview'),
msg('Bhote Koshi Upper Valley'),
msg('Upper Valley Collapse'),
msg('Debris-Dammed Lake'),
msg('Nepal-China Border Gate'),
msg('Timure Evidence Cluster'),
msg('Syabru Besi Passage'),
msg('Bidur / Trishuli Consequence'),
msg('Second landslide'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Devighat / Taadi Khola Bridge'),
msg('Final view'),
]);
/**
* Display title for a scene: the localized recipe name while the stored title
* still matches the built-in verbatim, the stored title otherwise.
* @param {{id?: string, title?: string}} scene
* @returns {string}
*/
export function displaySceneTitle(scene) {
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
if (builtin?.title && scene.title === builtin.title) return t(builtin.title);
const title = scene?.title || '';
const numbered = /^Scene (\d+)$/.exec(title);
return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : title;
}
/**
* Display title for a shot: built-in titles and the numbered "Shot N"
* fallback localize; any other title shows as stored.
* @param {string} title
* @returns {string}
*/
export function displayShotTitle(title) {
const text = typeof title === 'string' ? title : '';
if (BUILTIN_SHOT_TITLES.has(text)) return t(text);
const numbered = /^Shot (\d+)$/.exec(text);
return numbered ? t('Shot {n}', { n: Number(numbered[1]) }) : text;
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more