mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
32b11e27de
176 files changed
+9713
-2210
No files matched your search
+3
-1
@@ -21,7 +21,9 @@
|
|||||||
switch in the Visual Presets dock, then the browser language; English stays
|
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
|
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
|
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,
|
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||||
|
|||||||
@@ -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
|
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||||
station names) and attribution.
|
station names) and attribution.
|
||||||
- **Gaps.** Strings added after #601's extraction (for example the Street Level
|
- **Unmarked text.** The same check lists UI text that is not marked at all
|
||||||
panel) are still raw English; there is no lint for unmarked UI text yet.
|
(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
|
## Canonical Docs Order
|
||||||
|
|
||||||
|
|||||||
+29
-9
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
|
||||||
|
|
||||||
## After you add or change text
|
## After you add or change text
|
||||||
|
|
||||||
```sh
|
```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 -- --missing ru # the missing ru entries as JSON
|
||||||
npm run i18n -- --prune # drop entries whose English no longer exists
|
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.
|
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
|
## Where things live
|
||||||
|
|
||||||
| File | What it holds |
|
| File | What it holds |
|
||||||
| ------------------------------ | ----------------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
| `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/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/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/same-as-english.json` | per language, the entries that stay as in English on purpose |
|
||||||
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
|
| `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
@@ -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 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.
|
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ The `many` form is the `other` text with «de» after the number: «{count} de c
|
|||||||
| retry / retrying | reintento / reintentando |
|
| retry / retrying | reintento / reintentando |
|
||||||
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
|
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
|
||||||
| Provider Settings | Ajustes de proveedores |
|
| 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 |
|
| enable / disable | activar / desactivar |
|
||||||
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
|
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
|
||||||
| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares |
|
| 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 |
|
| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura |
|
||||||
| feather / fade | difuminado / desvanecimiento |
|
| feather / fade | difuminado / desvanecimiento |
|
||||||
| detection overlay | superposición de detección |
|
| 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) |
|
| 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) |
|
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
|
||||||
| PREV / NEXT | ANT. / SIG. |
|
| PREV / NEXT | ANT. / SIG. |
|
||||||
| Previous / Next (button text) | Anterior / Siguiente |
|
| Previous / Next (button text) | Anterior / Siguiente |
|
||||||
| L / R (relative bearing) | I / D |
|
| 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 |
|
| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO |
|
||||||
| click / click to … | haz clic / clic para … |
|
| click / click to … | haz clic / clic para … |
|
||||||
+39
-30
@@ -21,36 +21,45 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
|
|||||||
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
|
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
|
||||||
| `other` | fractions: 1.5, 2.25… | 1,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
|
## Glossary
|
||||||
|
|
||||||
| English | Русский |
|
| English | Русский |
|
||||||
| ------------------------------------------ | -------------------------------------------------- |
|
| ------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
| layer / data layer | слой / слой данных |
|
| layer / data layer | слой / слой данных |
|
||||||
| feed | поток данных (short: поток) |
|
| feed | поток данных (short: поток) |
|
||||||
| source / data source | источник / источник данных |
|
| source / data source | источник / источник данных |
|
||||||
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
||||||
| SIMULATED | СИМУЛЯЦИЯ |
|
| SIMULATED | СИМУЛЯЦИЯ |
|
||||||
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
||||||
| retry | повтор |
|
| retry | повтор |
|
||||||
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
||||||
| Provider Settings | Настройки провайдеров |
|
| Provider Settings | Настройки провайдеров |
|
||||||
| globe / map / map source | глобус / карта / источник карты |
|
| globe / map / map source | глобус / карта / источник карты |
|
||||||
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
|
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
|
||||||
| vessel, ship / vessels | судно / суда |
|
| vessel, ship / vessels | судно / суда |
|
||||||
| satellite | спутник |
|
| satellite | спутник |
|
||||||
| launch (rocket) / space mission | пуск / космическая миссия |
|
| launch (rocket) / space mission | пуск / космическая миссия |
|
||||||
| contact / tracking | контакт / отслеживание |
|
| contact / tracking | контакт / отслеживание |
|
||||||
| installation, site | объект |
|
| installation, site | объект |
|
||||||
| camera / CCTV camera | камера / камера CCTV |
|
| camera / CCTV camera | камера / камера CCTV |
|
||||||
| earthquake / active fire | землетрясение / активный пожар |
|
| earthquake / active fire | землетрясение / активный пожар |
|
||||||
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
|
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
|
||||||
| briefing / panel / dock | брифинг / панель / док |
|
| briefing / panel / dock | брифинг / панель / док |
|
||||||
| preset / visual preset | пресет / визуальный пресет |
|
| preset / visual preset | пресет / визуальный пресет |
|
||||||
| Scope (circular viewport mask) | прицел |
|
| Scope (circular viewport mask) | прицел |
|
||||||
| heading / altitude / ground speed | курс / высота / путевая скорость |
|
| heading / altitude / ground speed | курс / высота / путевая скорость |
|
||||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
||||||
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке |
|
||||||
| Previous / Next (button text) | Назад / Далее |
|
| Street View (Google) | Просмотр улиц (как в русских Google Картах) |
|
||||||
| L / R (relative bearing) | Л / П |
|
| 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
@@ -1,11 +1,16 @@
|
|||||||
// Keeps the locale catalogs in step with the code.
|
// 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 -- --prune also drop entries the code no longer uses
|
||||||
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
|
// 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/,
|
// 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.
|
// 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 { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
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 ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
|
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 STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
|
||||||
const CALL = new RegExp(
|
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',
|
'g',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,26 +99,19 @@ export function extractMessages() {
|
|||||||
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
|
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
|
||||||
};
|
};
|
||||||
|
|
||||||
const sources = trackedFiles(
|
const sources = sourceFiles();
|
||||||
'src/*.js',
|
|
||||||
'src/*.mjs',
|
|
||||||
'src/**/*.js',
|
|
||||||
'src/**/*.mjs',
|
|
||||||
).filter(
|
|
||||||
(file) =>
|
|
||||||
!file.startsWith('src/i18n/') &&
|
|
||||||
!/\.test\.mjs$|\/testSupport\//.test(file),
|
|
||||||
);
|
|
||||||
for (const file of sources) {
|
for (const file of sources) {
|
||||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||||
for (const match of source.matchAll(CALL)) {
|
for (const match of source.matchAll(CALL)) {
|
||||||
const line = source.slice(0, match.index).split('\n').length;
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
const where = `${file}:${line}`;
|
const where = `${file}:${line}`;
|
||||||
if (match[2]) {
|
if (match[1]) {
|
||||||
if (match[2][0] === '`' && match[2].includes('${')) continue;
|
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
|
||||||
add(unquote(match[2]), where);
|
} else if (match[4]) {
|
||||||
|
if (match[4][0] === '`' && match[4].includes('${')) continue;
|
||||||
|
add(unquote(match[4]), where);
|
||||||
} else {
|
} 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;
|
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) =>
|
const placeholders = (text) =>
|
||||||
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
|
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
|
||||||
.map((m) => m[1])
|
.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.
|
* Compare one catalog with the extracted messages.
|
||||||
|
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
|
||||||
* @returns {{missing: string[], stale: string[], errors: string[]}}
|
* @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()
|
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||||
.pluralCategories;
|
.pluralCategories;
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -192,6 +476,14 @@ export function checkCatalog(locale, catalog, messages) {
|
|||||||
errors.push(`"${text}" must be a string`);
|
errors.push(`"${text}" must be a string`);
|
||||||
} else if (placeholders(entry) !== placeholders(text)) {
|
} else if (placeholders(entry) !== placeholders(text)) {
|
||||||
errors.push(`"${text}" placeholders differ: ${entry}`);
|
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));
|
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
|
||||||
@@ -249,6 +541,15 @@ function main(args) {
|
|||||||
console.log(`${locale}: ${messages.size} messages, complete`);
|
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;
|
return failed ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -455,7 +455,9 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js",
|
||||||
|
"src/ui/keyGuidance.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -502,7 +504,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -556,7 +559,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -569,7 +573,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -712,7 +717,9 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -822,7 +829,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -880,7 +888,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1003,7 +1012,13 @@
|
|||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
"src/layers/recentImagery/thumbnails.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1130,7 +1145,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"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"]
|
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -1161,7 +1177,8 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"src/i18n/locales/es.json",
|
||||||
"src/i18n/locales/ru.json"
|
"src/i18n/locales/ru.json",
|
||||||
|
"src/ui/unitLabels.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1356,7 +1373,11 @@
|
|||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/sources/vectorTiles.js",
|
"src/sources/vectorTiles.js",
|
||||||
"src/sources/phaseTiming.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"]
|
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -2341,7 +2362,11 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -2880,7 +2905,10 @@
|
|||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
"src/i18n/locale.js",
|
"src/i18n/locale.js",
|
||||||
"src/i18n/locales/es.json",
|
"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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3120,7 +3148,12 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
"src/layers/directions/source.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -3398,7 +3431,11 @@
|
|||||||
"src/sources/overpassFeaturesRecords.js",
|
"src/sources/overpassFeaturesRecords.js",
|
||||||
"src/sources/overpassFeatures.js",
|
"src/sources/overpassFeatures.js",
|
||||||
"src/sources/featureSource.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"]
|
"external": ["cesium", "egm96-universal", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -3480,7 +3517,11 @@
|
|||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/perimeters/cards.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
|
|||||||
for (let i = 0; i < 20; i += 1) {
|
for (let i = 0; i < 20; i += 1) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
|
// The label is translated; the machine state is on the control.
|
||||||
if (status && status !== 'CONNECTING') break;
|
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
|
||||||
|
if (status && status !== 'connecting') break;
|
||||||
}
|
}
|
||||||
let diag = null;
|
let diag = null;
|
||||||
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
||||||
|
|||||||
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'highlight',
|
type: 'highlight',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
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',
|
label: 'Palace of Fine Arts',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'area',
|
type: 'area',
|
||||||
target: 'Presidio of San Francisco',
|
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)',
|
label: 'The Presidio (former Army base)',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
footprint: true,
|
footprint: true,
|
||||||
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'pin',
|
type: 'pin',
|
||||||
target: 'Letterman Digital Arts Center, San Francisco',
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||||
label: 'ILM / Lucasfilm',
|
label: 'ILM / Lucasfilm',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
|
|||||||
type: 'arrow',
|
type: 'arrow',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
toTarget: 'Marina District, 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',
|
label: 'next to the Marina',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'highlight',
|
type: 'highlight',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
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',
|
label: 'Palace of Fine Arts',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
},
|
},
|
||||||
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
|
|||||||
type: 'arrow',
|
type: 'arrow',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
toTarget: 'Marina Green, 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',
|
label: 'next to the Marina',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'area',
|
type: 'area',
|
||||||
target: 'Presidio of San Francisco',
|
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',
|
label: 'The Presidio — a former Army base',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
footprint: true,
|
footprint: true,
|
||||||
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'pin',
|
type: 'pin',
|
||||||
target: 'Letterman Digital Arts Center, San Francisco',
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||||
label: 'ILM / Lucasfilm',
|
label: 'ILM / Lucasfilm',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'route',
|
type: 'route',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
|
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||||
label: 'Crissy Field shoreline',
|
label: 'Crissy Field shoreline',
|
||||||
points: [
|
points: [
|
||||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||||
|
|||||||
@@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
// a limit rather than the harmless tail of a double-click.
|
// a limit rather than the harmless tail of a double-click.
|
||||||
if (reason === 'full')
|
if (reason === 'full')
|
||||||
setHint(
|
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')
|
else if (reason === 'invalid')
|
||||||
setHint(t('That point is off the globe — click on the world.'));
|
setHint(t('That point is off the globe — click on the world.'));
|
||||||
@@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (destroyed || attempt !== generation) return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
// Somebody else is using the pointer — possibly an older instance of
|
// Somebody else is using the pointer — possibly an older instance of
|
||||||
// this same tool that has not finished tearing down. Say so instead of
|
// this same tool that has not finished tearing down. Say so instead of
|
||||||
// half-starting.
|
// 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;
|
return active;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import {
|
|||||||
getActiveTrackedReadoutId,
|
getActiveTrackedReadoutId,
|
||||||
TRACKED_OVERLAY_SOURCE_ID,
|
TRACKED_OVERLAY_SOURCE_ID,
|
||||||
} from '../data/trackedReadout.js';
|
} 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).
|
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||||
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.poly);
|
group.appendChild(parts.poly);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.path);
|
group.appendChild(parts.path);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
});
|
});
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.leader);
|
group.appendChild(parts.leader);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
}
|
}
|
||||||
rec.anno = anno;
|
rec.anno = anno;
|
||||||
if (anno.outlineUnavailable && rec.parts.label) {
|
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);
|
sizeCallout(rec.parts.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
|
|||||||
const notice = document.createElement('div');
|
const notice = document.createElement('div');
|
||||||
notice.id = 'status';
|
notice.id = 'status';
|
||||||
notice.textContent =
|
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 " +
|
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||||
"View panels in this conversation hold them. Use Open in God's " +
|
"View panels in this conversation hold them. Use Open in God's " +
|
||||||
'Eye View above, or show it in a new conversation.';
|
'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?.displayMode) displayMode = context.displayMode;
|
||||||
if (context?.availableDisplayModes)
|
if (context?.availableDisplayModes)
|
||||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
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.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||||
};
|
};
|
||||||
expand.addEventListener('click', () => {
|
expand.addEventListener('click', () => {
|
||||||
@@ -640,6 +642,7 @@ export function panelRuntime(config) {
|
|||||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||||
appInfo: {
|
appInfo: {
|
||||||
name: 'gods-eye-view',
|
name: 'gods-eye-view',
|
||||||
|
// i18n-ignore: product name sent to the MCP host
|
||||||
title: "God's Eye View",
|
title: "God's Eye View",
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
},
|
},
|
||||||
|
|||||||
+647
-183
File diff suppressed because it is too large.
Load diff
+26
-5
@@ -83,11 +83,32 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
|||||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
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(
|
export function formatCockpitContextScope(
|
||||||
subjectLabel,
|
subjectLabel,
|
||||||
radiusM,
|
radiusM,
|
||||||
installationCoverage = null,
|
installationCoverage = null,
|
||||||
|
format = ENGLISH_CONTEXT_SCOPE,
|
||||||
) {
|
) {
|
||||||
const normalizedLabel =
|
const normalizedLabel =
|
||||||
typeof subjectLabel === 'string'
|
typeof subjectLabel === 'string'
|
||||||
@@ -97,12 +118,12 @@ export function formatCockpitContextScope(
|
|||||||
: '—';
|
: '—';
|
||||||
const radiusKm =
|
const radiusKm =
|
||||||
Number.isFinite(radiusM) && radiusM >= 0
|
Number.isFinite(radiusM) && radiusM >= 0
|
||||||
? String(Math.round(radiusM / 1000))
|
? Math.round(radiusM / 1000)
|
||||||
: '—';
|
: null;
|
||||||
const coverage =
|
const coverage =
|
||||||
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
const base = format.window({ label: normalizedLabel, radiusKm });
|
||||||
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
|
return coverage ? format.installations({ scope: base, coverage }) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return seven 30-degree compass divisions centered on a heading. */
|
/** Return seven 30-degree compass divisions centered on a heading. */
|
||||||
|
|||||||
+206
-53
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
|
|||||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||||
import { CockpitViewController } from './ui/cockpitController.js';
|
import { CockpitViewController } from './ui/cockpitController.js';
|
||||||
const previousDocument = globalThis.document;
|
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
|
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||||
// object reference alone is insufficient if reconciliation disconnects it.
|
// object reference alone is insufficient if reconciliation disconnects it.
|
||||||
@@ -22,23 +24,60 @@ function fixture() {
|
|||||||
this.className = '';
|
this.className = '';
|
||||||
this._text = '';
|
this._text = '';
|
||||||
this.classList = {
|
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 isConnected() {
|
||||||
get firstElementChild() { return this.children[0] || null; }
|
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||||
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(''); }
|
get firstElementChild() {
|
||||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
return this.children[0] || null;
|
||||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
}
|
||||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
get nextElementSibling() {
|
||||||
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
|
return (
|
||||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
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) {
|
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;
|
if (node === anchor) return node;
|
||||||
node.remove();
|
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);
|
this.children.splice(index, 0, node);
|
||||||
node.parentNode = this;
|
node.parentNode = this;
|
||||||
return node;
|
return node;
|
||||||
@@ -49,7 +88,10 @@ function fixture() {
|
|||||||
document.activeElement = document.body;
|
document.activeElement = document.body;
|
||||||
focusLosses += 1;
|
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;
|
this.parentNode = null;
|
||||||
}
|
}
|
||||||
replaceChildren(...nodes) {
|
replaceChildren(...nodes) {
|
||||||
@@ -59,20 +101,32 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
matches(selector) {
|
matches(selector) {
|
||||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
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();
|
: this.tagName === selector.toUpperCase();
|
||||||
}
|
}
|
||||||
closest(selector) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
querySelectorAll(selector) {
|
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() {
|
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;
|
focusCalls += 1;
|
||||||
document.activeElement = this;
|
document.activeElement = this;
|
||||||
}
|
}
|
||||||
@@ -85,7 +139,11 @@ function fixture() {
|
|||||||
controller.services = { formatAwarenessLabel };
|
controller.services = { formatAwarenessLabel };
|
||||||
controller.signalList = new Node('ol');
|
controller.signalList = new Node('ol');
|
||||||
controller.signalToggle = new Node('button');
|
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.briefPageIndex = 0;
|
||||||
controller.signalItems = [];
|
controller.signalItems = [];
|
||||||
controller.signalSignatures = new Map();
|
controller.signalSignatures = new Map();
|
||||||
@@ -93,64 +151,131 @@ function fixture() {
|
|||||||
const display = new Node('button');
|
const display = new Node('button');
|
||||||
const radio = new Node('button');
|
const radio = new Node('button');
|
||||||
const outside = 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 = [];
|
const selected = [];
|
||||||
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
|
controller.services.militaryAwarenessLayer = {
|
||||||
const click = event => controller.handleSignalClick(event);
|
focusTarget: (...args) => selected.push(args),
|
||||||
|
};
|
||||||
|
const click = (event) => controller.handleSignalClick(event);
|
||||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||||
const tab = () => {
|
const tab = () => {
|
||||||
const nodes = document.body.querySelectorAll('button');
|
const nodes = document.body.querySelectorAll('button');
|
||||||
const index = nodes.indexOf(document.activeElement);
|
const index = nodes.indexOf(document.activeElement);
|
||||||
nodes[(index + 1) % nodes.length].focus();
|
nodes[(index + 1) % nodes.length].focus();
|
||||||
};
|
};
|
||||||
return { controller, document, buttons, display, radio, outside, selected, click, tab,
|
return {
|
||||||
losses: () => focusLosses, focusCalls: () => focusCalls };
|
controller,
|
||||||
|
document,
|
||||||
|
buttons,
|
||||||
|
display,
|
||||||
|
radio,
|
||||||
|
outside,
|
||||||
|
selected,
|
||||||
|
click,
|
||||||
|
tab,
|
||||||
|
losses: () => focusLosses,
|
||||||
|
focusCalls: () => focusCalls,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = (id, overrides = {}) => ({
|
const item = (id, overrides = {}) => ({
|
||||||
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
|
key: `flight:flights:${id}`,
|
||||||
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
|
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) => ({
|
const snapshot = (distanceM = 2000) => ({
|
||||||
evaluatedAt: 1_700_000_000_000,
|
evaluatedAt: 1_700_000_000_000,
|
||||||
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
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', () => {
|
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||||
const target = f.buttons()[1]; target.focus();
|
const target = f.buttons()[1];
|
||||||
|
target.focus();
|
||||||
const calls = f.focusCalls();
|
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.buttons()[1], target);
|
||||||
assert.equal(f.document.activeElement, target);
|
assert.equal(f.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
|
assert.equal(
|
||||||
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
|
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', () => {
|
test('signal content and accessible name update without replacing the active target', () => {
|
||||||
const f = fixture(); render(f, [item('a')]);
|
const f = fixture();
|
||||||
const target = f.buttons()[0]; target.focus();
|
render(f, [item('a')]);
|
||||||
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
|
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(f.buttons()[0], target);
|
||||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||||
assert.match(target.textContent, /RENAMED/);
|
assert.match(target.textContent, /RENAMED/);
|
||||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
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.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
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 f = fixture();
|
||||||
const target = f.buttons()[1]; target.focus();
|
render(
|
||||||
|
f,
|
||||||
|
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||||
|
);
|
||||||
|
const target = f.buttons()[1];
|
||||||
|
target.focus();
|
||||||
const calls = f.focusCalls();
|
const calls = f.focusCalls();
|
||||||
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
|
for (const order of [
|
||||||
render(f, order.map((id) => item(id)));
|
['d', 'c', 'b', 'a'],
|
||||||
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
|
['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.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
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', () => {
|
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 f = fixture();
|
||||||
const target = f.buttons()[1]; target.focus();
|
render(f, [item('a'), item('b')]);
|
||||||
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
|
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.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
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);
|
f.click(event);
|
||||||
assert.equal(event.prevented, true);
|
assert.equal(event.prevented, true);
|
||||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
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 f = fixture();
|
||||||
const removed = f.buttons()[1]; removed.focus();
|
render(f, [item('a'), item('b')]);
|
||||||
|
const removed = f.buttons()[1];
|
||||||
|
removed.focus();
|
||||||
const calls = f.focusCalls();
|
const calls = f.focusCalls();
|
||||||
render(f, [item('a')]);
|
render(f, [item('a')]);
|
||||||
assert.equal(removed.isConnected, false);
|
assert.equal(removed.isConnected, false);
|
||||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||||
assert.equal(f.focusCalls(), calls + 1);
|
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);
|
assert.equal(f.document.activeElement, f.display);
|
||||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
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();
|
f.tab();
|
||||||
assert.equal(f.document.activeElement, f.radio);
|
assert.equal(f.document.activeElement, f.radio);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||||
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
|
const f = fixture();
|
||||||
const removed = f.buttons()[0]; removed.focus();
|
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||||
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
|
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];
|
const target = f.buttons()[0];
|
||||||
assert.notEqual(target, removed);
|
assert.notEqual(target, removed);
|
||||||
assert.equal(removed.isConnected, false);
|
assert.equal(removed.isConnected, false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { t, msg } from '../i18n/index.js';
|
||||||
|
|
||||||
const YOUTUBE_HOSTS = new Set([
|
const YOUTUBE_HOSTS = new Set([
|
||||||
'youtube.com',
|
'youtube.com',
|
||||||
@@ -39,6 +40,57 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
|||||||
facebook: Object.freeze(['https://www.facebook.com']),
|
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 _xWidgetsPromise = null;
|
||||||
let _facebookSdkPromise = null;
|
let _facebookSdkPromise = null;
|
||||||
let _facebookEmbedSequence = 0;
|
let _facebookEmbedSequence = 0;
|
||||||
@@ -240,7 +292,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
|||||||
prior?.();
|
prior?.();
|
||||||
} finally {
|
} finally {
|
||||||
if (facebookSdkReady(globalRef.FB)) finish();
|
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;
|
globalRef.fbAsyncInit = ready;
|
||||||
@@ -256,12 +308,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
|||||||
}
|
}
|
||||||
script.addEventListener?.(
|
script.addEventListener?.(
|
||||||
'error',
|
'error',
|
||||||
() => finish(new Error('Facebook player API could not load.')),
|
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
timer =
|
timer =
|
||||||
globalRef.setTimeout?.(
|
globalRef.setTimeout?.(
|
||||||
() => finish(new Error('Facebook player API timed out.')),
|
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||||
EMBED_TIMEOUT_MS,
|
EMBED_TIMEOUT_MS,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -398,7 +450,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
};
|
};
|
||||||
const ready = () => {
|
const ready = () => {
|
||||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
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}"]`);
|
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||||
if (!script) {
|
if (!script) {
|
||||||
@@ -411,7 +463,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
}
|
}
|
||||||
script.addEventListener?.(
|
script.addEventListener?.(
|
||||||
'error',
|
'error',
|
||||||
() => finish(new Error('X embed could not load.')),
|
() => finish(new Error(msg('X embed could not load.'))),
|
||||||
{
|
{
|
||||||
once: true,
|
once: true,
|
||||||
},
|
},
|
||||||
@@ -440,7 +492,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
timer = setTimeout(
|
timer = setTimeout(
|
||||||
() => finish(new Error('X embed timed out.')),
|
() => finish(new Error(msg('X embed timed out.'))),
|
||||||
EMBED_TIMEOUT_MS,
|
EMBED_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -450,9 +502,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
return _xWidgetsPromise;
|
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) {
|
function providerLabel(provider) {
|
||||||
return (
|
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?.(() => {
|
globalRef.setTimeout?.(() => {
|
||||||
if (!session.active || session.ready) return;
|
if (!session.active || session.ready) return;
|
||||||
session.error = new Error(
|
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);
|
session.onError?.(session.error);
|
||||||
}, EMBED_TIMEOUT_MS) ?? null;
|
}, EMBED_TIMEOUT_MS) ?? null;
|
||||||
@@ -857,7 +919,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
startAtSec,
|
startAtSec,
|
||||||
endAtSec,
|
endAtSec,
|
||||||
});
|
});
|
||||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
iframe.title = t('Preloading {provider} media', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
});
|
||||||
iframe.tabIndex = -1;
|
iframe.tabIndex = -1;
|
||||||
iframe.setAttribute('aria-hidden', 'true');
|
iframe.setAttribute('aria-hidden', 'true');
|
||||||
iframe.loading = 'eager';
|
iframe.loading = 'eager';
|
||||||
@@ -868,7 +932,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mountX(current, token) {
|
async function mountX(current, token) {
|
||||||
current.status.textContent = 'LOADING X POST…';
|
current.status.textContent = t('LOADING X POST…');
|
||||||
try {
|
try {
|
||||||
const api = await xLoader(documentRef, globalRef);
|
const api = await xLoader(documentRef, globalRef);
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
@@ -885,13 +949,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
);
|
);
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
if (!widget)
|
if (!widget)
|
||||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
throw new Error(
|
||||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
msg('The X post is unavailable or cannot be embedded.'),
|
||||||
|
);
|
||||||
|
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||||
position();
|
position();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.card.classList.add('is-unavailable');
|
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();
|
position();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,19 +972,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
session.shouldPlay = autoplay === true;
|
session.shouldPlay = autoplay === true;
|
||||||
session.onReady = () => {
|
session.onReady = () => {
|
||||||
if (token !== generation || record !== current) return;
|
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');
|
current.card.classList.remove('is-unavailable');
|
||||||
position();
|
position();
|
||||||
};
|
};
|
||||||
session.onError = (error) => {
|
session.onError = (error) => {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.card.classList.add('is-unavailable');
|
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();
|
position();
|
||||||
};
|
};
|
||||||
session.onPlaybackBlocked = () => {
|
session.onPlaybackBlocked = () => {
|
||||||
if (token !== generation || record !== current) return;
|
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();
|
if (session.ready) session.onReady();
|
||||||
else if (session.error) session.onError(session.error);
|
else if (session.error) session.onError(session.error);
|
||||||
@@ -968,7 +1040,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
card.dataset.provider = source.provider;
|
card.dataset.provider = source.provider;
|
||||||
card.setAttribute(
|
card.setAttribute(
|
||||||
'aria-label',
|
'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(
|
const leader = documentRef.createElementNS(
|
||||||
'http://www.w3.org/2000/svg',
|
'http://www.w3.org/2000/svg',
|
||||||
@@ -982,17 +1059,19 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
player.className = 'bhote-embedded-callout-player';
|
player.className = 'bhote-embedded-callout-player';
|
||||||
const footer = documentRef.createElement('footer');
|
const footer = documentRef.createElement('footer');
|
||||||
const title = documentRef.createElement('strong');
|
const title = documentRef.createElement('strong');
|
||||||
title.textContent = String(
|
title.textContent = bhoteEventText(
|
||||||
observation?.shortTitle || observation?.title || 'Witness source',
|
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
const status = documentRef.createElement('small');
|
const status = documentRef.createElement('small');
|
||||||
status.setAttribute('role', 'status');
|
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');
|
const link = documentRef.createElement('a');
|
||||||
link.href = source.url;
|
link.href = source.url;
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
link.rel = 'noopener noreferrer';
|
link.rel = 'noopener noreferrer';
|
||||||
link.textContent = 'OPEN ORIGINAL ↗';
|
link.textContent = t('OPEN ORIGINAL ↗');
|
||||||
footer.append(title, status, link);
|
footer.append(title, status, link);
|
||||||
const body = documentRef.createElement('div');
|
const body = documentRef.createElement('div');
|
||||||
body.className = 'bhote-embedded-callout-body';
|
body.className = 'bhote-embedded-callout-body';
|
||||||
@@ -1032,7 +1111,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
url.searchParams.set('origin', globalRef.location.origin);
|
url.searchParams.set('origin', globalRef.location.origin);
|
||||||
iframe.src = url.href;
|
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 =
|
iframe.allow =
|
||||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||||
iframe.allowFullscreen = true;
|
iframe.allowFullscreen = true;
|
||||||
@@ -1049,7 +1133,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
record.youtubeSession.getState().phase,
|
record.youtubeSession.getState().phase,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
position();
|
position();
|
||||||
},
|
},
|
||||||
@@ -1067,7 +1153,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
isCurrent: () => token === generation && record === current,
|
isCurrent: () => token === generation && record === current,
|
||||||
onStatus: (phase) => {
|
onStatus: (phase) => {
|
||||||
if (phase !== 'completed')
|
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
@@ -19,11 +19,13 @@ import {
|
|||||||
SOURCE_PATH_BEAT_IDS,
|
SOURCE_PATH_BEAT_IDS,
|
||||||
} from './bhoteKoshiShotPaths.js';
|
} from './bhoteKoshiShotPaths.js';
|
||||||
import {
|
import {
|
||||||
|
bhoteEventText,
|
||||||
createBhoteKoshiEmbeddedMedia,
|
createBhoteKoshiEmbeddedMedia,
|
||||||
resolveEmbeddedMediaSource,
|
resolveEmbeddedMediaSource,
|
||||||
} from './bhoteKoshiEmbeddedMedia.js';
|
} from './bhoteKoshiEmbeddedMedia.js';
|
||||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||||
import { createImagerySplit } from '../ui/imagerySplit.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_LAYER_ID = 'bhote-koshi-2026';
|
||||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
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));
|
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. */
|
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||||
export function evidenceCorroborationLabel(observation) {
|
export function evidenceCorroborationLabel(observation) {
|
||||||
const count = Math.max(
|
const count = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 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. */
|
/** 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.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
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) {
|
function ellipsizeEvidenceText(value, limit) {
|
||||||
@@ -704,7 +783,7 @@ export function updateEvidenceFrame(
|
|||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
ellipsizeEvidenceText(
|
ellipsizeEvidenceText(
|
||||||
observation.shortTitle || observation.title,
|
bhoteEventText(observation.shortTitle || observation.title),
|
||||||
layout.titleChars,
|
layout.titleChars,
|
||||||
).toUpperCase(),
|
).toUpperCase(),
|
||||||
16,
|
16,
|
||||||
@@ -713,10 +792,12 @@ export function updateEvidenceFrame(
|
|||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||||
const publisher = String(
|
const publisher = String(
|
||||||
observation.media?.publisher || 'SOURCE LINK',
|
observation.media?.publisher || t('SOURCE LINK'),
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
t('{publisher} / OPEN', {
|
||||||
|
publisher: ellipsizeEvidenceText(publisher, 24),
|
||||||
|
}),
|
||||||
16,
|
16,
|
||||||
infoY + 53,
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function createPanel(event, handlers) {
|
function createPanel(event, handlers) {
|
||||||
const panel = document.createElement('aside');
|
const panel = document.createElement('aside');
|
||||||
panel.id = 'bhote-koshi-event-panel';
|
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 = `
|
panel.innerHTML = `
|
||||||
<div class="bhote-event-header">
|
<div class="bhote-event-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||||
</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>
|
||||||
<div class="bhote-event-status-row">
|
<div class="bhote-event-status-row">
|
||||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||||
</div>
|
</div>
|
||||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||||
<div class="bhote-event-section-head">
|
<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>
|
<span data-role="split-readout">50 / 50</span>
|
||||||
</div>
|
</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">
|
<div class="bhote-event-dates">
|
||||||
<span data-role="before-date"></span>
|
<span data-role="before-date"></span>
|
||||||
<span data-role="after-date"></span>
|
<span data-role="after-date"></span>
|
||||||
@@ -842,44 +935,44 @@ function createPanel(event, handlers) {
|
|||||||
</section>
|
</section>
|
||||||
<section class="bhote-event-section">
|
<section class="bhote-event-section">
|
||||||
<div class="bhote-event-section-head">
|
<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>
|
<span data-role="time"></span>
|
||||||
</div>
|
</div>
|
||||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
<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="Story beat navigation">
|
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
<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">
|
<div class="bhote-event-story-current">
|
||||||
<span data-role="beat-index">01 / 06</span>
|
<span data-role="beat-index">01 / 06</span>
|
||||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||||
</div>
|
</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>
|
||||||
<div class="bhote-event-actions">
|
<div class="bhote-event-actions">
|
||||||
<button type="button" data-action="play">▶ PLAY</button>
|
<button type="button" data-action="play">${escapeMarkup(t('▶ 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="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">◉ CINEMATIC</button>
|
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<details class="bhote-event-evidence">
|
<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>
|
<div class="bhote-event-report-list"></div>
|
||||||
</details>
|
</details>
|
||||||
<div class="bhote-event-credit">
|
<div class="bhote-event-credit">
|
||||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||||
</div>
|
</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);
|
const refs = collectPanelReferences(panel);
|
||||||
refs.beforeDate.textContent = event.imagery.before.label;
|
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||||
refs.afterDate.textContent = event.imagery.after.label;
|
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
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) {
|
for (const report of event.fieldReports) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
@@ -887,8 +980,8 @@ function createPanel(event, handlers) {
|
|||||||
const status = document.createElement('small');
|
const status = document.createElement('small');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'bhote-event-report';
|
button.className = 'bhote-event-report';
|
||||||
label.textContent = report.label;
|
label.textContent = bhoteEventText(report.label);
|
||||||
status.textContent = report.status;
|
status.textContent = bhoteEventText(report.status);
|
||||||
button.append(label, status);
|
button.append(label, status);
|
||||||
button.addEventListener('click', () => openExternal(report.url));
|
button.addEventListener('click', () => openExternal(report.url));
|
||||||
refs.reportList.appendChild(button);
|
refs.reportList.appendChild(button);
|
||||||
@@ -1205,7 +1298,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_embeddedMedia?.show?.({
|
_embeddedMedia?.show?.({
|
||||||
observation: {
|
observation: {
|
||||||
title: witness.title,
|
title: witness.title,
|
||||||
shortTitle: 'Rasuwagadhi witness',
|
shortTitle: t('Rasuwagadhi witness'),
|
||||||
media: { sourceUrl: witness.sourceUrl },
|
media: { sourceUrl: witness.sourceUrl },
|
||||||
},
|
},
|
||||||
anchor,
|
anchor,
|
||||||
@@ -2320,7 +2413,10 @@ export function createBhoteKoshiEventLayer({
|
|||||||
collisionGroup: 'ambient-card',
|
collisionGroup: 'ambient-card',
|
||||||
zIndex: 60,
|
zIndex: 60,
|
||||||
interactive: true,
|
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),
|
activate: () => openExternal(observation.media.sourceUrl),
|
||||||
minDistance: 0,
|
minDistance: 0,
|
||||||
maxDistance: 220000,
|
maxDistance: 220000,
|
||||||
@@ -2353,7 +2449,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
position: evidencePositionGetter(index, true),
|
position: evidencePositionGetter(index, true),
|
||||||
cullPosition: anchor,
|
cullPosition: anchor,
|
||||||
variant: 'label',
|
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: [],
|
details: [],
|
||||||
accent: WORLD_OVERLAY_STYLE.accent,
|
accent: WORLD_OVERLAY_STYLE.accent,
|
||||||
priority: 80_000 - index,
|
priority: 80_000 - index,
|
||||||
@@ -2559,11 +2655,14 @@ export function createBhoteKoshiEventLayer({
|
|||||||
id: 'bhote-koshi-split-line',
|
id: 'bhote-koshi-split-line',
|
||||||
handleClass: 'bhote-koshi-split-handle',
|
handleClass: 'bhote-koshi-split-handle',
|
||||||
cssProperty: '--bhote-koshi-split',
|
cssProperty: '--bhote-koshi-split',
|
||||||
beforeTitle: event.imagery.before.label,
|
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||||
afterTitle: event.imagery.after.label,
|
afterTitle: imageryEndpointText(event.imagery.after),
|
||||||
ariaLabel: 'Historical reference and post-event image divider',
|
ariaLabel: t('Historical reference and post-event image divider'),
|
||||||
formatValueText: (beforePercent, afterPercent) =>
|
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,
|
onChange: setSplit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2595,7 +2694,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
setElementProperty(
|
setElementProperty(
|
||||||
button,
|
button,
|
||||||
'textContent',
|
'textContent',
|
||||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||||
);
|
);
|
||||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||||
}
|
}
|
||||||
@@ -2961,13 +3060,13 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.progressInput,
|
_panelRefs.progressInput,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? 'Seek Nepal scene clock'
|
? t('Seek Nepal scene clock')
|
||||||
: 'Schematic downstream progression',
|
: t('Schematic downstream progression'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.timelineLabel,
|
_panelRefs.timelineLabel,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||||
);
|
);
|
||||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
@@ -2980,22 +3079,22 @@ export function createBhoteKoshiEventLayer({
|
|||||||
);
|
);
|
||||||
const playLabel = sceneDirected
|
const playLabel = sceneDirected
|
||||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||||
? '… PLAYING SHOT'
|
? t('… PLAYING SHOT')
|
||||||
: '▶ PLAY SHOT'
|
: t('▶ PLAY SHOT')
|
||||||
: _playing
|
: _playing
|
||||||
? 'Ⅱ PAUSE'
|
? t('Ⅱ PAUSE')
|
||||||
: _progress >= 1
|
: _progress >= 1
|
||||||
? _cinematicReplayArmed
|
? _cinematicReplayArmed
|
||||||
? '↺ REPLAY CINEMATIC'
|
? t('↺ REPLAY CINEMATIC')
|
||||||
: '↺ REPLAY'
|
: t('↺ REPLAY')
|
||||||
: '▶ PLAY';
|
: t('▶ PLAY');
|
||||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
'textContent',
|
'textContent',
|
||||||
_sceneActionPending?.type === 'scene'
|
_sceneActionPending?.type === 'scene'
|
||||||
? '… PLAYING SCENE'
|
? t('… PLAYING SCENE')
|
||||||
: '▶ PLAY SCENE',
|
: t('▶ PLAY SCENE'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
@@ -3011,19 +3110,24 @@ export function createBhoteKoshiEventLayer({
|
|||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
'aria-label',
|
'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(
|
setElementProperty(
|
||||||
_panelRefs.storyReplayButton,
|
_panelRefs.storyReplayButton,
|
||||||
'textContent',
|
'textContent',
|
||||||
'↺ FULL STORY',
|
t('↺ FULL STORY'),
|
||||||
);
|
);
|
||||||
if (!sceneDirected) {
|
if (!sceneDirected) {
|
||||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.storyReplayButton,
|
_panelRefs.storyReplayButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
'Replay the full reconstruction',
|
t('Replay the full reconstruction'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const beatIndex = currentEvidenceIndex();
|
const beatIndex = currentEvidenceIndex();
|
||||||
@@ -3047,18 +3151,26 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.beatTitle,
|
_panelRefs.beatTitle,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
? _sceneContext?.shotDisplayTitle ||
|
||||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
_sceneContext?.shotTitle ||
|
||||||
|
t('SCENE SHOT')
|
||||||
|
: observation?.shortTitle || observation?.title
|
||||||
|
? bhoteEventText(observation.shortTitle || observation.title)
|
||||||
|
: t('EVIDENCE'),
|
||||||
);
|
);
|
||||||
const sourceCount = Math.max(
|
const sourceCount = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||||
);
|
);
|
||||||
const beatMeta = [
|
const beatMeta = [
|
||||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
evidencePhaseLabel(observation?.phase),
|
||||||
'TIME UNVERIFIED',
|
t('TIME UNVERIFIED'),
|
||||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
sourceCount > 1
|
||||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||||
|
: '',
|
||||||
|
observation?.imageryCoverage === 'outside'
|
||||||
|
? t('OUTSIDE IMAGERY SWIPE')
|
||||||
|
: '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' / ');
|
.join(' / ');
|
||||||
@@ -3066,7 +3178,13 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.beatMeta,
|
_panelRefs.beatMeta,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected
|
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,
|
: beatMeta,
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
@@ -3093,18 +3211,23 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.playButton,
|
_panelRefs.playButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
? t('Play {shot}', {
|
||||||
: 'Play reconstruction',
|
shot:
|
||||||
|
_sceneContext?.shotDisplayTitle ||
|
||||||
|
_sceneContext?.shotTitle ||
|
||||||
|
t('current scene shot'),
|
||||||
|
})
|
||||||
|
: t('Play reconstruction'),
|
||||||
);
|
);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.previousButton,
|
_panelRefs.previousButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||||
);
|
);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.nextButton,
|
_panelRefs.nextButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.openSourceButton,
|
_panelRefs.openSourceButton,
|
||||||
@@ -3153,8 +3276,8 @@ export function createBhoteKoshiEventLayer({
|
|||||||
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
||||||
if (_panelRefs?.caveat) {
|
if (_panelRefs?.caveat) {
|
||||||
_panelRefs.caveat.textContent = imageryAvailable
|
_panelRefs.caveat.textContent = imageryAvailable
|
||||||
? _event?.reconstruction?.caveat || ''
|
? bhoteEventText(_event?.reconstruction?.caveat || '')
|
||||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||||
}
|
}
|
||||||
if (_panelRefs?.imageryCloudNote)
|
if (_panelRefs?.imageryCloudNote)
|
||||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||||
@@ -3717,7 +3840,9 @@ export function createBhoteKoshiEventLayer({
|
|||||||
count: _enabled ? 1 : 0,
|
count: _enabled ? 1 : 0,
|
||||||
status: _enabled ? 'nominal' : 'idle',
|
status: _enabled ? 'nominal' : 'idle',
|
||||||
source: 'Vantor + GeoPera',
|
source: 'Vantor + GeoPera',
|
||||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
coverage: _enabled
|
||||||
|
? t('Rasuwa · 26 Aug 2026')
|
||||||
|
: t('Event reconstruction'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getPlaybackState() {
|
getPlaybackState() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from './dataCredits.js';
|
} from './dataCredits.js';
|
||||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.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_LAYER_ID = 'bhote-koshi-locator';
|
||||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||||
@@ -51,23 +52,43 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
|
|||||||
const TRIGGER_SOURCE_URL =
|
const TRIGGER_SOURCE_URL =
|
||||||
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
'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({
|
const INCIDENT = Object.freeze({
|
||||||
lat: 28.3632333,
|
lat: 28.3632333,
|
||||||
lon: 85.4423639,
|
lon: 85.4423639,
|
||||||
heightM: 5200,
|
heightM: 5200,
|
||||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||||
subtitle: 'NEPAL · 26 AUG 2026',
|
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||||
});
|
});
|
||||||
const TRIGGER_RECORD = Object.freeze({
|
const TRIGGER_RECORD = Object.freeze({
|
||||||
lat: 28.271,
|
lat: 28.271,
|
||||||
lon: 85.515,
|
lon: 85.515,
|
||||||
heightM: 4200,
|
heightM: 4200,
|
||||||
title: 'USGS INITIAL REPORT',
|
title: msg('USGS INITIAL REPORT'),
|
||||||
details: Object.freeze([
|
details: Object.freeze([
|
||||||
'26 AUG 2026 · 08:37 NPT',
|
msg('26 AUG 2026 · 08:37 NPT'),
|
||||||
'28.2710° N · 85.5150° E',
|
msg('28.2710° N · 85.5150° E'),
|
||||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||||
@@ -90,11 +111,13 @@ const NEPAL = Object.freeze({
|
|||||||
lat: 28.3949,
|
lat: 28.3949,
|
||||||
lon: 84.124,
|
lon: 84.124,
|
||||||
heightM: 6800,
|
heightM: 6800,
|
||||||
|
// i18n-ignore: country name, shown as is
|
||||||
title: 'NEPAL',
|
title: 'NEPAL',
|
||||||
});
|
});
|
||||||
const NEARBY_CITIES = Object.freeze([
|
const NEARBY_CITIES = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'kathmandu',
|
id: 'kathmandu',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'KATHMANDU',
|
title: 'KATHMANDU',
|
||||||
lat: 27.7017,
|
lat: 27.7017,
|
||||||
lon: 85.3206,
|
lon: 85.3206,
|
||||||
@@ -103,6 +126,7 @@ const NEARBY_CITIES = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'bhaktapur',
|
id: 'bhaktapur',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'BHAKTAPUR',
|
title: 'BHAKTAPUR',
|
||||||
lat: 27.673,
|
lat: 27.673,
|
||||||
lon: 85.43,
|
lon: 85.43,
|
||||||
@@ -111,6 +135,7 @@ const NEARBY_CITIES = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'dhulikhel',
|
id: 'dhulikhel',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'DHULIKHEL',
|
title: 'DHULIKHEL',
|
||||||
lat: 27.6221,
|
lat: 27.6221,
|
||||||
lon: 85.5428,
|
lon: 85.5428,
|
||||||
@@ -394,8 +419,8 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'card',
|
variant: 'card',
|
||||||
paintLane: 'thumbnail',
|
paintLane: 'thumbnail',
|
||||||
title: subject.title,
|
title: t(subject.title),
|
||||||
details: [subject.subtitle],
|
details: [t(subject.subtitle)],
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: () => presentation().alpha,
|
sourceAlpha: () => presentation().alpha,
|
||||||
presentationScale: () => presentation().scale,
|
presentationScale: () => presentation().scale,
|
||||||
@@ -536,8 +561,8 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'card',
|
variant: 'card',
|
||||||
paintLane: 'thumbnail',
|
paintLane: 'thumbnail',
|
||||||
title: TRIGGER_RECORD.title,
|
title: t(TRIGGER_RECORD.title),
|
||||||
details: [...TRIGGER_RECORD.details],
|
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: () => presentation().alpha,
|
sourceAlpha: () => presentation().alpha,
|
||||||
presentationScale: () => presentation().scale,
|
presentationScale: () => presentation().scale,
|
||||||
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
paintLane: 'ambient-label',
|
paintLane: 'ambient-label',
|
||||||
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
|
||||||
details: [],
|
details: [],
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: progress,
|
sourceAlpha: progress,
|
||||||
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'nepal-context',
|
id: 'nepal-context',
|
||||||
|
// i18n-ignore: country name
|
||||||
label: 'NEPAL',
|
label: 'NEPAL',
|
||||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Show the Nepal context callout without a border or country fill',
|
'Show the Nepal context callout without a border or country fill',
|
||||||
|
),
|
||||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-regional',
|
id: 'bhote-koshi-regional',
|
||||||
|
// i18n-ignore: river name (Bhote Koshi)
|
||||||
label: 'BHOTE',
|
label: 'BHOTE',
|
||||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-city-context',
|
id: 'bhote-koshi-city-context',
|
||||||
label: 'CITIES',
|
label: t('CITIES'),
|
||||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Keep the regional incident context and introduce nearby city labels',
|
'Keep the regional incident context and introduce nearby city labels',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-incident-places',
|
id: 'bhote-koshi-incident-places',
|
||||||
label: 'PLACES',
|
label: t('PLACES'),
|
||||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-flood-path',
|
id: 'bhote-koshi-flood-path',
|
||||||
label: 'PATH',
|
label: t('PATH'),
|
||||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-path-overview',
|
id: 'bhote-koshi-path-overview',
|
||||||
label: 'OVERVIEW',
|
label: t('OVERVIEW'),
|
||||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
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 },
|
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-trigger-record',
|
id: 'bhote-koshi-trigger-record',
|
||||||
label: 'RECORD',
|
label: t('RECORD'),
|
||||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Reveal the trigger record while making a slow camera approach',
|
'Reveal the trigger record while making a slow camera approach',
|
||||||
|
),
|
||||||
params: {
|
params: {
|
||||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
preview: true,
|
preview: true,
|
||||||
|
|||||||
+408
-121
@@ -1,9 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFile } from 'node:fs/promises';
|
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 { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
import { SceneDirector } from '../scenes/director.js';
|
import { SceneDirector } from '../scenes/director.js';
|
||||||
import { createPlaybackClock } from '../director/clock.js';
|
import { createPlaybackClock } from '../director/clock.js';
|
||||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||||
@@ -29,8 +33,12 @@ function viewerFixture() {
|
|||||||
return {
|
return {
|
||||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||||
dataSources: {
|
dataSources: {
|
||||||
add(source) { added.push(source); },
|
add(source) {
|
||||||
remove(source) { removed.push(source); },
|
added.push(source);
|
||||||
|
},
|
||||||
|
remove(source) {
|
||||||
|
removed.push(source);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
_test: { added, removed },
|
_test: { added, removed },
|
||||||
};
|
};
|
||||||
@@ -38,7 +46,9 @@ function viewerFixture() {
|
|||||||
|
|
||||||
function nonRunningAnimation() {
|
function nonRunningAnimation() {
|
||||||
return {
|
return {
|
||||||
scheduleFrame() { return 1; },
|
scheduleFrame() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
cancelFrame() {},
|
cancelFrame() {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,24 +68,68 @@ function overlayHostFixture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
assert.deepEqual(
|
||||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
closeRegionRing([
|
||||||
]);
|
[80, 26],
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
[88, 26],
|
||||||
[80, 26], [88, 26], [80, 26],
|
[88, 30],
|
||||||
]);
|
]),
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
[
|
||||||
|
[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', () => {
|
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
const main = [
|
||||||
const island = [[82, 25], [83, 25], [82, 25]];
|
[80, 26],
|
||||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
[88, 26],
|
||||||
assert.equal(largestGeoJsonRing({
|
[88, 30],
|
||||||
type: 'MultiPolygon',
|
[80, 30],
|
||||||
coordinates: [[island], [main]],
|
[80, 26],
|
||||||
}), main);
|
];
|
||||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
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', () => {
|
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(10, 0, 0),
|
||||||
new Cesium.Cartesian3(20, 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), [
|
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||||
positions[0],
|
positions[0],
|
||||||
new Cesium.Cartesian3(5, 0, 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({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
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),
|
requestRender: (reason) => renders.push(reason),
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
|||||||
const publication = overlayHost._test.publications.at(-1);
|
const publication = overlayHost._test.publications.at(-1);
|
||||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||||
assert.equal(publication.entries.length, 2);
|
assert.equal(publication.entries.length, 2);
|
||||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
const nepal = publication.entries.find(
|
||||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
({ id }) => id === 'nepal-context-callout',
|
||||||
|
);
|
||||||
|
const incident = publication.entries.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||||
|
);
|
||||||
assert.equal(nepal.variant, 'label');
|
assert.equal(nepal.variant, 'label');
|
||||||
assert.equal(nepal.title, 'NEPAL');
|
assert.equal(nepal.title, 'NEPAL');
|
||||||
assert.equal(nepal.anchorDot, true);
|
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.contentAlpha(), 1);
|
||||||
assert.equal(incident.presentationScale(), 1);
|
assert.equal(incident.presentationScale(), 1);
|
||||||
assert.equal(layer.getStats().status, 'nominal');
|
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.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||||
|
|
||||||
assert.equal(await layer.disable(), true);
|
assert.equal(await layer.disable(), true);
|
||||||
assert.equal(viewer._test.removed.length, 1);
|
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');
|
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({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
|||||||
|
|
||||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
assert.deepEqual(
|
||||||
'nepal-context-halo',
|
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||||
]);
|
['nepal-context-halo'],
|
||||||
|
);
|
||||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||||
assert.equal(nepalLabel.variant, 'label');
|
assert.equal(nepalLabel.variant, 'label');
|
||||||
assert.equal(nepalLabel.placement, 'right');
|
assert.equal(nepalLabel.placement, 'right');
|
||||||
assert.equal(nepalLabel.anchorDot, true);
|
assert.equal(nepalLabel.anchorDot, true);
|
||||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
assert.deepEqual(
|
||||||
{ id: 'nepal-context', active: true },
|
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
|
||||||
{ id: 'bhote-koshi-regional', active: false },
|
[
|
||||||
{ id: 'bhote-koshi-city-context', active: false },
|
{ id: 'nepal-context', active: true },
|
||||||
{ id: 'bhote-koshi-incident-places', active: false },
|
{ id: 'bhote-koshi-regional', active: false },
|
||||||
{ id: 'bhote-koshi-flood-path', active: false },
|
{ id: 'bhote-koshi-city-context', active: false },
|
||||||
{ id: 'bhote-koshi-path-overview', active: false },
|
{ id: 'bhote-koshi-incident-places', active: false },
|
||||||
{ id: 'bhote-koshi-trigger-record', 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 });
|
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
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(
|
assert.equal(
|
||||||
overlayHost._test.publications.at(-1).entries
|
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
overlayHost._test.publications
|
||||||
|
.at(-1)
|
||||||
|
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||||
'card',
|
'card',
|
||||||
);
|
);
|
||||||
|
|
||||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
assert.deepEqual(
|
||||||
'nepal-context-halo',
|
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||||
]);
|
['nepal-context-halo'],
|
||||||
|
);
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
|||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
let pulseFrame = null;
|
let pulseFrame = null;
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => null, requestRender() {},
|
boundaryResolver: async () => null,
|
||||||
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
now: () => nowMs,
|
now: () => nowMs,
|
||||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
scheduleFrame(callback) {
|
||||||
|
pulseFrame = callback;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
cancelFrame() {},
|
cancelFrame() {},
|
||||||
});
|
});
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
const source = viewer._test.added[0];
|
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.length, 16);
|
||||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||||
assert.equal(source.entities.values.length, 15);
|
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 id = `bhote-koshi-place-${point.id}`;
|
||||||
const pin = labels.find((entry) => entry.id === id);
|
const pin = labels.find((entry) => entry.id === id);
|
||||||
assert.ok(pin, point.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');
|
assert.equal(pin.variant, 'label');
|
||||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
assert.ok(
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
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;
|
nowMs = 349 + index * 650;
|
||||||
assert.equal(pin.sourceAlpha(), 0);
|
assert.equal(pin.sourceAlpha(), 0);
|
||||||
nowMs += 1;
|
nowMs += 1;
|
||||||
assert.equal(pin.sourceAlpha(), 0);
|
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;
|
nowMs += 520;
|
||||||
assert.equal(pin.sourceAlpha(), 1);
|
assert.equal(pin.sourceAlpha(), 1);
|
||||||
}
|
}
|
||||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||||
// A repeat halo cycle must not erase already revealed labels.
|
// A repeat halo cycle must not erase already revealed labels.
|
||||||
nowMs = 350 + 15 * 650;
|
nowMs = 350 + 15 * 650;
|
||||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
assert.equal(
|
||||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
source.entities
|
||||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
.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');
|
assert.equal(typeof pulseFrame, 'function');
|
||||||
pulseFrame();
|
pulseFrame();
|
||||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
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();
|
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 source = viewer._test.added[0];
|
||||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||||
const labels = overlayHost._test.publications.at(-1).entries;
|
const labels = overlayHost._test.publications.at(-1).entries;
|
||||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
const trigger = labels.find(
|
||||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
({ 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(source.entities.values.length, 1);
|
||||||
assert.equal(labels.length, 15);
|
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.equal(path.polyline.clampToGround.getValue(), true);
|
||||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
assert.ok(
|
||||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
path.polyline.material.color
|
||||||
Cesium.Math.EPSILON7,
|
.getValue()
|
||||||
));
|
.equalsEpsilon(
|
||||||
|
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||||
|
Cesium.Math.EPSILON7,
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.equal(path.show.getValue(), false);
|
assert.equal(path.show.getValue(), false);
|
||||||
assert.equal(trigger.sourceAlpha(), 1);
|
assert.equal(trigger.sourceAlpha(), 1);
|
||||||
assert.equal(bidur.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;
|
const halfwayCount = path.polyline.positions.getValue().length;
|
||||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||||
nowMs = 4250;
|
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]);
|
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||||
// More observation pins must not extend or straighten the sourced route.
|
// More observation pins must not extend or straighten the sourced route.
|
||||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
assert.deepEqual(
|
||||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
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.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
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();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
|||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
|||||||
assert.equal(labels.length, 15);
|
assert.equal(labels.length, 15);
|
||||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
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(pin.sourceAlpha(), 1);
|
||||||
}
|
}
|
||||||
assert.equal(path.show.getValue(), true);
|
assert.equal(path.show.getValue(), true);
|
||||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
assert.equal(
|
||||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
path.polyline.positions.getValue().length,
|
||||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
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-fill'), undefined);
|
||||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
source.entities.getById(
|
||||||
|
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
|
||||||
|
),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
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();
|
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 publication = overlayHost._test.publications.at(-1);
|
||||||
const record = publication.entries[0];
|
const record = publication.entries[0];
|
||||||
|
|
||||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'bhote-koshi-flood-path',
|
source.entities.values.map(({ id }) => id),
|
||||||
'bhote-koshi-trigger-record-halo',
|
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
|
||||||
]);
|
);
|
||||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||||
assert.deepEqual(record.details, [
|
assert.deepEqual(record.details, [
|
||||||
'26 AUG 2026 · 08:37 NPT',
|
'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 = {
|
viewer.camera = {
|
||||||
moveStart,
|
moveStart,
|
||||||
moveEnd,
|
moveEnd,
|
||||||
flyTo(options) { flights.push(options); },
|
flyTo(options) {
|
||||||
|
flights.push(options);
|
||||||
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
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.length, 1);
|
||||||
assert.equal(flights[0].duration, 4.6);
|
assert.equal(flights[0].duration, 4.6);
|
||||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
assert.ok(
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
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);
|
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||||
flights[0].complete();
|
flights[0].complete();
|
||||||
assert.equal(flights.length, 2);
|
assert.equal(flights.length, 2);
|
||||||
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
const moveStart = new Cesium.Event();
|
const moveStart = new Cesium.Event();
|
||||||
const moveEnd = new Cesium.Event();
|
const moveEnd = new Cesium.Event();
|
||||||
viewer.camera = {
|
viewer.camera = {
|
||||||
moveStart, moveEnd,
|
moveStart,
|
||||||
flyTo(options) { flights.push(options); },
|
moveEnd,
|
||||||
|
flyTo(options) {
|
||||||
|
flights.push(options);
|
||||||
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
timers.set(++nextTimer, callback);
|
timers.set(++nextTimer, callback);
|
||||||
return nextTimer;
|
return nextTimer;
|
||||||
},
|
},
|
||||||
cancelDelay(id) { timers.delete(id); },
|
cancelDelay(id) {
|
||||||
|
timers.delete(id);
|
||||||
|
},
|
||||||
...nonRunningAnimation(),
|
...nonRunningAnimation(),
|
||||||
});
|
});
|
||||||
const director = {
|
const director = {
|
||||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
_clock: createPlaybackClock({
|
||||||
|
isRunning: () => false,
|
||||||
|
timingForShot: () => null,
|
||||||
|
onProgress() {},
|
||||||
|
}),
|
||||||
_scenePacks: createDefaultScenePacks(),
|
_scenePacks: createDefaultScenePacks(),
|
||||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||||
viewer,
|
viewer,
|
||||||
_sceneSeekGeneration: 0,
|
_sceneSeekGeneration: 0,
|
||||||
_loadGeneration: 0,
|
_loadGeneration: 0,
|
||||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
_cancelActiveSceneTravel:
|
||||||
|
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
layer.setParams({
|
||||||
|
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
|
});
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
if (phase === 'move-end') moveStart.raiseEvent();
|
if (phase === 'move-end') moveStart.raiseEvent();
|
||||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||||
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
callbacks[0]();
|
callbacks[0]();
|
||||||
moveEnd.raiseEvent();
|
moveEnd.raiseEvent();
|
||||||
for (const flight of oldFlights) flight.complete();
|
for (const flight of oldFlights) flight.complete();
|
||||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
assert.equal(
|
||||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
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)();
|
callbacks.at(-1)();
|
||||||
const successor = flights.at(-1);
|
const successor = flights.at(-1);
|
||||||
const count = flights.length;
|
const count = flights.length;
|
||||||
callbacks[0]();
|
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');
|
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||||
successor.complete();
|
successor.complete();
|
||||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
assert.equal(
|
||||||
} finally { await layer.destroy(); }
|
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({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
|||||||
const source = viewer._test.added[0];
|
const source = viewer._test.added[0];
|
||||||
const borderFill = source.entities.getById('nepal-border-fill');
|
const borderFill = source.entities.getById('nepal-border-fill');
|
||||||
const entries = overlayHost._test.publications.at(-1).entries;
|
const entries = overlayHost._test.publications.at(-1).entries;
|
||||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
const incident = entries.find(
|
||||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
);
|
||||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
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(borderFill.show.getValue(), true);
|
||||||
assert.equal(incident.contentAlpha(), 1);
|
assert.equal(incident.contentAlpha(), 1);
|
||||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
assert.ok(
|
||||||
variant === 'label' && anchorDot === true
|
[kathmandu, bhaktapur, dhulikhel].every(
|
||||||
)));
|
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||||
assert.equal(dhulikhel.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;
|
nowMs = 1300;
|
||||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
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();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await layer.enable(viewer);
|
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('nepal-context-halo'));
|
||||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||||
'bhote-koshi-incident-callout',
|
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||||
]);
|
);
|
||||||
assert.equal(layer.getStats().status, 'degraded');
|
assert.equal(layer.getStats().status, 'degraded');
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = originalWarn;
|
console.warn = originalWarn;
|
||||||
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
|||||||
const viewer = viewerFixture();
|
const viewer = viewerFixture();
|
||||||
const overlayHost = overlayHostFixture();
|
const overlayHost = overlayHostFixture();
|
||||||
let releaseBoundary;
|
let releaseBoundary;
|
||||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
const boundary = new Promise((resolve) => {
|
||||||
|
releaseBoundary = resolve;
|
||||||
|
});
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => boundary,
|
boundaryResolver: async () => boundary,
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
|||||||
|
|
||||||
const enabling = layer.enable(viewer);
|
const enabling = layer.enable(viewer);
|
||||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
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(
|
||||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
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.equal(overlayHost._test.publications.length, 1);
|
||||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
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;
|
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.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.equal(overlayHost._test.publications.length, 2);
|
||||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||||
'bhote-koshi-incident-callout',
|
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||||
]);
|
);
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
|||||||
const viewer = viewerFixture();
|
const viewer = viewerFixture();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let releaseBoundary;
|
let releaseBoundary;
|
||||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
const boundary = new Promise((resolve) => {
|
||||||
|
releaseBoundary = resolve;
|
||||||
|
});
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => boundary,
|
boundaryResolver: async () => boundary,
|
||||||
requestRender() {},
|
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 });
|
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||||
controller.abort();
|
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');
|
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||||
assert.equal(viewer._test.removed.length, 1);
|
assert.equal(viewer._test.removed.length, 1);
|
||||||
});
|
});
|
||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
createApplicationDirectionsLayer,
|
createApplicationDirectionsLayer,
|
||||||
} from './directions.js';
|
} from './directions.js';
|
||||||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.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 {
|
import {
|
||||||
claimPointer,
|
claimPointer,
|
||||||
isPointerFree,
|
isPointerFree,
|
||||||
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
|
|||||||
lastUpdate: 5,
|
lastUpdate: 5,
|
||||||
});
|
});
|
||||||
assert.equal(ready.count, 3);
|
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(
|
assert.equal(
|
||||||
ready.loadingLabel,
|
ready.loadingLabel,
|
||||||
'21 km · 25 min · Drive',
|
'21\u00a0km · 25\u00a0min · Drive',
|
||||||
'the summary is the row detail line',
|
'the summary is the row detail line',
|
||||||
);
|
);
|
||||||
assert.equal(ready.loading, undefined);
|
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);
|
const copy = directionsStepCopy(route.steps, 1);
|
||||||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||||||
assert.deepEqual(copy.details, [
|
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',
|
'Then: Arrive at your destination',
|
||||||
]);
|
]);
|
||||||
const last = directionsStepCopy(route.steps, 2);
|
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(
|
assert.deepEqual(
|
||||||
list.items.map((item) => item.lead),
|
list.items.map((item) => item.lead),
|
||||||
['8 m', '240 m', '0 m'],
|
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
list.items.map((item) => item.params),
|
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(toasts.length, 1, 'a successful arming is silent');
|
||||||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
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м');
|
||||||
|
});
|
||||||
@@ -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 { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
@@ -458,19 +461,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
|||||||
optionOwner: 'flights',
|
optionOwner: 'flights',
|
||||||
optionKey: 'selectedFlightsTrackingId',
|
optionKey: 'selectedFlightsTrackingId',
|
||||||
expiryWindowMs: 90_000,
|
expiryWindowMs: 90_000,
|
||||||
label: 'flight',
|
label: msg('flight'),
|
||||||
}),
|
}),
|
||||||
military: Object.freeze({
|
military: Object.freeze({
|
||||||
optionOwner: 'flights',
|
optionOwner: 'flights',
|
||||||
optionKey: 'selectedMilitaryTrackingId',
|
optionKey: 'selectedMilitaryTrackingId',
|
||||||
expiryWindowMs: 45_000,
|
expiryWindowMs: 45_000,
|
||||||
label: 'military flight',
|
label: msg('military flight'),
|
||||||
}),
|
}),
|
||||||
satellites: Object.freeze({
|
satellites: Object.freeze({
|
||||||
optionOwner: 'satellites',
|
optionOwner: 'satellites',
|
||||||
optionKey: 'selectedSatTrackingId',
|
optionKey: 'selectedSatTrackingId',
|
||||||
expiryWindowMs: 300_000,
|
expiryWindowMs: 300_000,
|
||||||
label: 'satellite',
|
label: msg('satellite'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+17
-12
@@ -26,47 +26,47 @@ import { t, msg } from '../i18n/index.js';
|
|||||||
*/
|
*/
|
||||||
export const SATELLITE_CLASSES = Object.freeze({
|
export const SATELLITE_CLASSES = Object.freeze({
|
||||||
station: 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
|
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||||
// leak into another domain. Slightly warm so it also separates from the
|
// leak into another domain. Slightly warm so it also separates from the
|
||||||
// pure white the civilian air fleet draws in.
|
// pure white the civilian air fleet draws in.
|
||||||
color: '#fff6e5',
|
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({
|
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
|
// Cyan — the strongest accent goes to the class users actually name
|
||||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
// ("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.
|
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||||
color: '#4fd8ff',
|
color: '#4fd8ff',
|
||||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||||
}),
|
}),
|
||||||
geo: Object.freeze({
|
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
|
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||||
// as one clean equatorial ring at globe scale.
|
// as one clean equatorial ring at globe scale.
|
||||||
color: '#c89bff',
|
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({
|
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
|
// 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
|
// cyan it used to hold and recedes behind the three classes that mean
|
||||||
// something. Still well clear of the dense shell by luminance.
|
// something. Still well clear of the dense shell by luminance.
|
||||||
color: '#9fb3c4',
|
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({
|
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
|
// 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
|
// 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
|
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||||
color: '#54697f',
|
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.
|
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
|
||||||
* @returns {string} Display label.
|
* @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 { 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;
|
return subtype ? `${base} · ${subtype}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+483
-144
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
|
|||||||
rememberFirstRunSessionDismissed(session);
|
rememberFirstRunSessionDismissed(session);
|
||||||
assert.equal(session.read(), 'dismissed');
|
assert.equal(session.read(), 'dismissed');
|
||||||
// Gone for THIS session...
|
// 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.
|
// ...and back in the next one, because sessionStorage did not survive it.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage,
|
shouldShowFirstRun({
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
storage,
|
||||||
location: { search: '' },
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
}), true);
|
location: { search: '' },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
// Session dismissal must never have written the durable key.
|
// Session dismissal must never have written the durable key.
|
||||||
assert.equal(storage.read(), null);
|
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);
|
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
||||||
setFirstRunSuppressed(true, storage);
|
setFirstRunSuppressed(true, storage);
|
||||||
assert.equal(storage.read(), 'suppressed');
|
assert.equal(storage.read(), 'suppressed');
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage,
|
shouldShowFirstRun({
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
storage,
|
||||||
location: { search: '' },
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
}), false);
|
location: { search: '' },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Unticking before dismissing takes the suppression back.
|
// Unticking before dismissing takes the suppression back.
|
||||||
setFirstRunSuppressed(false, storage);
|
setFirstRunSuppressed(false, storage);
|
||||||
assert.equal(storage.read(), null);
|
assert.equal(storage.read(), null);
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage,
|
shouldShowFirstRun({
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
storage,
|
||||||
location: { search: '' },
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
}), true);
|
location: { search: '' },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
||||||
setFirstRunSuppressed(true, storage);
|
setFirstRunSuppressed(true, storage);
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
shouldShowFirstRun({
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||||
location: { search: '' },
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
}), true);
|
location: { search: '' },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('welcome params work in both directions and outrank both suppressions', () => {
|
test('welcome params work in both directions and outrank both suppressions', () => {
|
||||||
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
||||||
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
||||||
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
|
shouldShowFirstRun({
|
||||||
}), true);
|
storage: suppressed,
|
||||||
|
sessionStorageRef: dismissed,
|
||||||
|
location: { search: '?welcome=1' },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
// ?welcome=0 suppresses a session that would otherwise see it.
|
// ?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.
|
// A share link outranks everything, including the replay hatch.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
|
shouldShowFirstRun({
|
||||||
}), false);
|
hasShareState: true,
|
||||||
|
...fresh(),
|
||||||
|
location: { search: '?welcome=1' },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a share link never sees the launcher — its author already chose the view', () => {
|
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', () => {
|
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
||||||
const blocked = {
|
const blocked = {
|
||||||
getItem: () => { throw new Error('blocked'); },
|
getItem: () => {
|
||||||
setItem: () => { throw new Error('blocked'); },
|
throw new Error('blocked');
|
||||||
removeItem: () => { 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(() => setFirstRunSuppressed(true, blocked));
|
||||||
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(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
|
// before the function body starts, so the SecurityError escapes every
|
||||||
// try/catch in the module and the launcher silently never appears.
|
// try/catch in the module and the launcher silently never appears.
|
||||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||||
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
const savedSession = Object.getOwnPropertyDescriptor(
|
||||||
|
globalThis,
|
||||||
|
'sessionStorage',
|
||||||
|
);
|
||||||
const hostile = {
|
const hostile = {
|
||||||
configurable: true,
|
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, 'localStorage', hostile);
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
||||||
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
|||||||
} finally {
|
} finally {
|
||||||
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
||||||
else delete globalThis.localStorage;
|
else delete globalThis.localStorage;
|
||||||
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
if (savedSession)
|
||||||
|
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||||
else delete globalThis.sessionStorage;
|
else delete globalThis.sessionStorage;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no storage is touched from a default parameter position', () => {
|
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
|
// 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.
|
// 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(
|
assert.doesNotMatch(
|
||||||
code,
|
code,
|
||||||
/=\s*globalThis\.(local|session)Storage/,
|
/=\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.
|
// The only reads of the global areas live inside the guarded resolver.
|
||||||
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
||||||
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
|
assert.equal(
|
||||||
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
|
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(
|
assert.equal(
|
||||||
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
||||||
2,
|
2,
|
||||||
'both global storage reads must be inside resolveStore, inside its try',
|
'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 readStored\(kind, injected, key\)/);
|
||||||
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
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 ────────
|
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
||||||
|
|
||||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
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));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
|
||||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
'cockpit-mode',
|
||||||
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
|
'recording-mode',
|
||||||
);
|
'scene-playback-mode',
|
||||||
|
'ui-clean-view',
|
||||||
|
]);
|
||||||
// A surface that hides the card in CSS but is missing from the JS list would
|
// 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.
|
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
||||||
const hideRule = css.slice(
|
const hideRule = css.slice(
|
||||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
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(
|
assert.deepEqual(
|
||||||
inCss.sort(),
|
inCss.sort(),
|
||||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||||
'every screen-claiming surface must appear in BOTH lists',
|
'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', () => {
|
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);
|
assert.equal(exclusiveSurfaceActive(make([])), false);
|
||||||
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
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(make(['some-other-class'])), false);
|
||||||
assert.equal(exclusiveSurfaceActive(undefined), 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', () => {
|
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\(\)/);
|
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
||||||
// Real visibility, not just the class: the class survives while CSS hides the
|
// 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.
|
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
assert.match(
|
||||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
module,
|
||||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
/&&\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(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||||
/!isActive\(\)/,
|
/!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', () => {
|
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));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
// 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
|
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
||||||
// buried launcher and wrote the session flag while the lightbox stayed open.
|
// buried launcher and wrote the session flag while the lightbox stayed open.
|
||||||
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
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 {'));
|
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
||||||
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
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
|
// ...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.
|
// launcher acting under an overlay can never become why ESC stopped working.
|
||||||
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
|
assert.match(
|
||||||
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
|
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*\}/);
|
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', () => {
|
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 ui = fs.readFileSync(
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
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
|
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
// 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'));
|
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||||
assert.match(claim, /event\.preventDefault\(\);/);
|
assert.match(claim, /event\.preventDefault\(\);/);
|
||||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||||
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
|
assert.doesNotMatch(
|
||||||
'the plain form leaves the launcher listening and is the defect itself');
|
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,
|
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||||
// whether or not that surface remembered to silence us.
|
// whether or not that surface remembered to silence us.
|
||||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
const keyboard = fs.readFileSync(
|
||||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const handler = keyboard.slice(
|
||||||
|
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||||
/event\.defaultPrevented\) return;/,
|
/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"', () => {
|
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
|
// 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
|
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||||
// while it is already guaranteed to return next session.
|
// while it is already guaranteed to return next session.
|
||||||
const blocked = {
|
const blocked = {
|
||||||
getItem: () => null,
|
getItem: () => null,
|
||||||
setItem: () => { throw new Error('blocked'); },
|
setItem: () => {
|
||||||
removeItem: () => { throw new Error('blocked'); },
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
||||||
assert.equal(setFirstRunSuppressed(false, 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 onSuppressChange = (event) => {'),
|
||||||
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
||||||
);
|
);
|
||||||
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
assert.match(
|
||||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
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', () => {
|
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 module = fs.readFileSync(
|
||||||
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
|
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
|
// 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
|
// 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('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
||||||
module.indexOf(' const onViewportResize'),
|
module.indexOf(' const onViewportResize'),
|
||||||
);
|
);
|
||||||
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
|
assert.ok(
|
||||||
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
|
accepted.length > 0,
|
||||||
'the pin must cover the function the note governs, not just the note');
|
'the acceptance must be written where the next editor will read it',
|
||||||
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
|
);
|
||||||
'the acceptance is the decision NOT to time this out');
|
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(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', () => {
|
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));
|
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
|
// 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.
|
// not exist, which is worse than no affordance at all.
|
||||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
assert.match(
|
||||||
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
|
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.
|
// ...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/);
|
assert.match(
|
||||||
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
css,
|
||||||
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
|
/\.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.
|
// Rotating a phone changes which tiles fit, so it re-measures.
|
||||||
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
||||||
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the launcher yields on engage and waits when a surface is already up', () => {
|
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
|
// Both directions from one observer: yield if it is up and something takes
|
||||||
// the screen; wait to reveal if something already has it.
|
// the screen; wait to reveal if something already has it.
|
||||||
assert.match(
|
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.
|
// not see a new hold because of onboarding chrome.
|
||||||
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
||||||
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
||||||
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
|
assert.doesNotMatch(
|
||||||
|
module,
|
||||||
|
/setInterval|requestAnimationFrame\(function poll/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
// ── 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 };
|
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
deps: {
|
deps: {
|
||||||
setContextMode: async (mode) => {
|
setContextMode: async (mode) => {
|
||||||
calls.contextModes.push(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) => {
|
setLayerEnabled: async (layerId) => {
|
||||||
calls.layerIds.push(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
|
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||||
// globe-LOD declutter first.
|
// globe-LOD declutter first.
|
||||||
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
||||||
'contacts', 'space-missions', 'environmental', 'explore',
|
'contacts',
|
||||||
|
'space-missions',
|
||||||
|
'environmental',
|
||||||
|
'explore',
|
||||||
]);
|
]);
|
||||||
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
|
assert.equal(
|
||||||
'the infrastructure mission must be gone, not dormant');
|
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 () => {
|
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 spy = missionSpy();
|
||||||
const outcome = await runFirstRunChoice(choice, spy.deps);
|
const outcome = await runFirstRunChoice(choice, spy.deps);
|
||||||
assert.equal(outcome.ok, true);
|
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
|
// 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
|
// post-launch, and the note must stay where the next editor will read it
|
||||||
// rather than being re-discovered as a launcher bug.
|
// rather than being re-discovered as a launcher bug.
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
|
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, /KEY REQUIRED/);
|
||||||
assert.match(table, /src\/loadingFeedback\.js/);
|
assert.match(table, /src\/loadingFeedback\.js/);
|
||||||
assert.match(table, /LEDGERED post-launch/);
|
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.equal(outcome.ok, true);
|
||||||
assert.deepEqual(outcome.failedLayerIds, []);
|
assert.deepEqual(outcome.failedLayerIds, []);
|
||||||
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
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(
|
assert.doesNotMatch(
|
||||||
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
||||||
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
/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']);
|
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
||||||
|
|
||||||
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
||||||
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
|
const flightDown = missionSpy({
|
||||||
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
|
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 () => {
|
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
||||||
const spy = missionSpy();
|
const spy = missionSpy();
|
||||||
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
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.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 () => {
|
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('EARTH_WATCH').title, 'EARTH WATCH');
|
||||||
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
||||||
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
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 ─────────────────
|
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
||||||
|
|
||||||
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
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 module = fs.readFileSync(
|
||||||
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
|
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
|
// 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.
|
// 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',
|
'_setModels3dParams',
|
||||||
'setFeather',
|
'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
|
// The only durable panel write is the Context reveal, and only on the Context
|
||||||
// missions — the globe missions open no panel at all.
|
// missions — the globe missions open no panel at all.
|
||||||
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
||||||
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
|
assert.equal(
|
||||||
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
|
panelWrites.length,
|
||||||
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
|
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', () => {
|
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');
|
const module = fs.readFileSync(
|
||||||
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
|
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']) {
|
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/);
|
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 ─────────────────────────────────
|
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
||||||
|
|
||||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||||
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const html = expandApplicationHtml(
|
||||||
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
|
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));
|
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.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, /<input type="checkbox" data-first-run-suppress \/>/);
|
||||||
assert.match(html, /<strong data-first-run-environmental-title>/);
|
assert.match(html, /<strong data-first-run-environmental-title>/);
|
||||||
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
// 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>
|
// 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.
|
// text counts; the comment beside it naturally says the words too.
|
||||||
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
|
const envTile = html.slice(
|
||||||
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
|
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, /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,
|
// 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
|
// 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.
|
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
html.includes(
|
||||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
'<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',
|
'the owner-authored first-run line must ship exactly as written',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Menu order is the owner's, read straight off the markup.
|
// 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]);
|
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
|
||||||
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
|
(match) => match[1],
|
||||||
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
|
);
|
||||||
'the removed tile must leave no markup behind');
|
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.match(startup, /styleManager\.initialRestorePromise/);
|
||||||
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
|
assert.ok(
|
||||||
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
|
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\.ui-clean-view #first-run-launcher/);
|
||||||
assert.match(css, /body\.recording-mode #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
|
// 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.
|
// 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)]
|
const reducedMotion = [
|
||||||
.map((match) => match[1]);
|
...css.matchAll(
|
||||||
|
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
|
||||||
|
),
|
||||||
|
].map((match) => match[1]);
|
||||||
assert.ok(
|
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 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.
|
// 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(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
|
// 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
|
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
||||||
// `[hidden] { display: none }`, which would strand the card in the
|
// `[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', () => {
|
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
|
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
// 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 = /);
|
assert.doesNotMatch(module, /button\.disabled = /);
|
||||||
// Tab is confined to the launcher, and ESC always releases it.
|
// 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\.activate\(\)/);
|
||||||
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
||||||
assert.match(keyboard, /event\.key !== 'Tab'/);
|
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', () => {
|
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
|
// 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 —
|
// 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.
|
// 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
|
// 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.
|
// 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'));
|
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
||||||
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
||||||
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
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
|
// Canonical serialization pins every tool name, description, property and
|
||||||
// ordering while allowing source formatting. Derived from the unchanged
|
// ordering while allowing source formatting. Derived from the unchanged
|
||||||
// release schema before formatting (the previous source-byte pin passed).
|
// release schema before formatting (the previous source-byte pin passed).
|
||||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
(tool) => tool.name !== 'set_cyber_sonar',
|
||||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
);
|
||||||
|
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.
|
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||||
const block = JSON.stringify(legacyTools);
|
const block = JSON.stringify(legacyTools);
|
||||||
@@ -680,7 +1000,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
|
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'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
|
// ...and the mapping that makes them reachable by voice is one instruction
|
||||||
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
// 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/,
|
/\n\s+'NAMED VIEWS are shorthand/,
|
||||||
'the mission mapping must be an active instruction entry, not commented out',
|
'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"));
|
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
||||||
for (const layerId of [
|
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('zoom_to_globe'));
|
||||||
assert.ok(paragraph.includes('set_layer_visibility'));
|
assert.ok(paragraph.includes('set_layer_visibility'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
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 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);
|
assert.ok(missionLayerIds.length > 0);
|
||||||
for (const layerId of missionLayerIds) {
|
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
@@ -27,7 +27,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
|
|||||||
import {
|
import {
|
||||||
hudSummaryMatchesProvenance,
|
hudSummaryMatchesProvenance,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenanceTag,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +35,9 @@ import {
|
|||||||
DEFAULT_HUD_LAYOUT,
|
DEFAULT_HUD_LAYOUT,
|
||||||
normalizeHudLayout,
|
normalizeHudLayout,
|
||||||
} from './hudLayouts.js';
|
} 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. */
|
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||||
const HUD_COLORS = {
|
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. */
|
/** Shader modes that automatically show the HUD overlay. */
|
||||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||||
|
|
||||||
@@ -186,6 +292,7 @@ export class IntelHUD {
|
|||||||
this._el = document.getElementById('intel-hud');
|
this._el = document.getElementById('intel-hud');
|
||||||
if (!this._el) return;
|
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 = `
|
this._el.innerHTML = `
|
||||||
<div class="hud-sonar" aria-hidden="true"></div>
|
<div class="hud-sonar" aria-hidden="true"></div>
|
||||||
|
|
||||||
@@ -200,7 +307,7 @@ export class IntelHUD {
|
|||||||
<div class="hud-content">
|
<div class="hud-content">
|
||||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||||
<div class="hud-system">${this._missionId} ${this._sensorId}</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-wrap">
|
||||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</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 class="hud-content" style="text-align:right">
|
||||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||||
<div id="hud-alt">ALT: --m SUN: --° EL</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>
|
||||||
<div class="hud-bracket">┘</div>
|
<div class="hud-bracket">┘</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,9 +463,11 @@ export class IntelHUD {
|
|||||||
const formatted = this._formatMGRS(mgrsStr);
|
const formatted = this._formatMGRS(mgrsStr);
|
||||||
mgrsLabel = formatted;
|
mgrsLabel = formatted;
|
||||||
const el = document.getElementById('hud-mgrs');
|
const el = document.getElementById('hud-mgrs');
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
if (el) el.textContent = `MGRS: ${formatted}`;
|
if (el) el.textContent = `MGRS: ${formatted}`;
|
||||||
} catch {
|
} catch {
|
||||||
const el = document.getElementById('hud-mgrs');
|
const el = document.getElementById('hud-mgrs');
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
if (el) el.textContent = 'MGRS: ---';
|
if (el) el.textContent = 'MGRS: ---';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +476,7 @@ export class IntelHUD {
|
|||||||
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
||||||
const bottomEl = document.getElementById('hud-bottom-line');
|
const bottomEl = document.getElementById('hud-bottom-line');
|
||||||
if (bottomEl) {
|
if (bottomEl) {
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +492,7 @@ export class IntelHUD {
|
|||||||
);
|
);
|
||||||
const gsdEl = document.getElementById('hud-gsd');
|
const gsdEl = document.getElementById('hud-gsd');
|
||||||
if (gsdEl)
|
if (gsdEl)
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||||
|
|
||||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
// 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 altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||||
if (altEl)
|
if (altEl)
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||||
|
|
||||||
// Collection timestamp
|
// Collection timestamp
|
||||||
@@ -403,6 +515,7 @@ export class IntelHUD {
|
|||||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||||
const s = String(now.getUTCSeconds()).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`;
|
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +524,7 @@ export class IntelHUD {
|
|||||||
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
||||||
const ona = Math.max(0, 90 + pitchDeg);
|
const ona = Math.max(0, 90 + pitchDeg);
|
||||||
const onaEl = document.getElementById('hud-ona');
|
const onaEl = document.getElementById('hud-ona');
|
||||||
|
// i18n-ignore: sensor readout codes, same in every language
|
||||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||||
|
|
||||||
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
||||||
@@ -638,7 +752,7 @@ export class IntelHUD {
|
|||||||
if (!m) return t('Awaiting telemetry...');
|
if (!m) return t('Awaiting telemetry...');
|
||||||
|
|
||||||
const modeEl = document.getElementById('hud-mode');
|
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 region = this._regionLabel(m.latDeg, m.lonDeg);
|
||||||
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
||||||
const band = this._viewBand(m.altM);
|
const band = this._viewBand(m.altM);
|
||||||
@@ -650,20 +764,41 @@ export class IntelHUD {
|
|||||||
// together, so they must never disagree. The view band above deliberately
|
// together, so they must never disagree. The view band above deliberately
|
||||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
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 =
|
const altTag =
|
||||||
altDisplayM >= 1000
|
altDisplayM >= 1000
|
||||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
? hudQuantity(altDisplayM / 1000, 'km', 1)
|
||||||
: `${Math.round(altDisplayM)}M`;
|
: hudQuantity(Math.round(altDisplayM), 'm');
|
||||||
const winTag = window
|
const winTag = window
|
||||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
|
||||||
: 'N/A';
|
: t('N/A');
|
||||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
// 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(
|
const provenance = localizedProvenanceTag(
|
||||||
this._dataManager?.getAll?.() || [],
|
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;
|
return provenance ? `${line} | ${provenance}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,8 +954,9 @@ export class IntelHUD {
|
|||||||
// Update mode label
|
// Update mode label
|
||||||
const modeEl = document.getElementById('hud-mode');
|
const modeEl = document.getElementById('hud-mode');
|
||||||
if (modeEl) {
|
if (modeEl) {
|
||||||
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
|
modeEl.textContent = MODE_LABELS[styleName]
|
||||||
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
|
? t(MODE_LABELS[styleName])
|
||||||
|
: styleName.toUpperCase();
|
||||||
}
|
}
|
||||||
// Update color scheme
|
// Update color scheme
|
||||||
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
||||||
|
|||||||
+25
-3
@@ -23,6 +23,17 @@
|
|||||||
*/
|
*/
|
||||||
export const NEAR_POI_MAX_KM = 150;
|
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`.
|
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
|
||||||
* @param {number} value Signed decimal degrees.
|
* @param {number} value Signed decimal degrees.
|
||||||
@@ -42,13 +53,24 @@ function coordinateTag(value, positive, negative) {
|
|||||||
* catalogue is empty.
|
* catalogue is empty.
|
||||||
* @param {number} latDeg Camera latitude in decimal degrees.
|
* @param {number} latDeg Camera latitude in decimal degrees.
|
||||||
* @param {number} lonDeg Camera longitude 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),
|
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
||||||
* otherwise `SECTOR <lat> <lon>`.
|
* otherwise `SECTOR <lat> <lon>`.
|
||||||
*/
|
*/
|
||||||
export function composeLocalityTag(nearest, latDeg, lonDeg) {
|
export function composeLocalityTag(
|
||||||
|
nearest,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
format = ENGLISH_LOCALITY,
|
||||||
|
) {
|
||||||
const distKm = Number(nearest?.distKm);
|
const distKm = Number(nearest?.distKm);
|
||||||
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
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 });
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ test('the NEAR bound is metro scale, not continental', () => {
|
|||||||
|
|
||||||
test('a landmark under the camera reads NEAR', () => {
|
test('a landmark under the camera reads NEAR', () => {
|
||||||
assert.equal(
|
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',
|
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
|
||||||
);
|
);
|
||||||
assert.equal(
|
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', () => {
|
test('the boundary is pinned on both sides, inclusive at the bound', () => {
|
||||||
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
|
const at = composeLocalityTag(
|
||||||
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
|
{ ...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/);
|
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');
|
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('southern and western hemispheres carry the right suffixes', () => {
|
test('southern and western hemispheres carry the right suffixes', () => {
|
||||||
// Rio and Honolulu — the two the fallback already handled correctly in the field.
|
// 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(
|
||||||
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
|
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 —
|
// 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',
|
'hud.js must import composeLocalityTag from ./hudLocality.js',
|
||||||
);
|
);
|
||||||
assert.equal(
|
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,
|
true,
|
||||||
'_composeSummary must build its locality tag through composeLocalityTag()',
|
'_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', () => {
|
test('a missing or malformed nearest POI never crashes the summary', () => {
|
||||||
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
||||||
assert.match(composeLocalityTag(undefined, 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 /);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 {Array<object>} [layers]
|
||||||
* @param {{ now?: number }} [options]
|
* @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 snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||||
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
|
|||||||
.filter(
|
.filter(
|
||||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
(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);
|
.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. */
|
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||||
|
|||||||
+111
-39
@@ -5,7 +5,7 @@ import {
|
|||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenanceTag,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
|
|||||||
return routes;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const headers = new Map();
|
const headers = new Map();
|
||||||
const req = {
|
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', () => {
|
test('recognizes only the exact deliberate no-key fallback response', () => {
|
||||||
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
||||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
assert.equal(
|
||||||
...UNCONFIGURED_PAYLOAD,
|
isHudSummaryUnconfigured(200, {
|
||||||
error: 'provider failed',
|
...UNCONFIGURED_PAYLOAD,
|
||||||
}), false);
|
error: 'provider failed',
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
}),
|
||||||
...UNCONFIGURED_PAYLOAD,
|
false,
|
||||||
summary: 'Unexpected provider output',
|
);
|
||||||
}), false);
|
assert.equal(
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
isHudSummaryUnconfigured(200, {
|
||||||
...UNCONFIGURED_PAYLOAD,
|
...UNCONFIGURED_PAYLOAD,
|
||||||
configured: true,
|
summary: 'Unexpected provider output',
|
||||||
}), false);
|
}),
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
false,
|
||||||
...UNCONFIGURED_PAYLOAD,
|
);
|
||||||
unexpected: true,
|
assert.equal(
|
||||||
}), false);
|
isHudSummaryUnconfigured(200, {
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
...UNCONFIGURED_PAYLOAD,
|
||||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
configured: true,
|
||||||
}), false);
|
}),
|
||||||
|
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', () => {
|
test('does not hide real provider and HTTP failures', () => {
|
||||||
assert.equal(isHudSummaryUnconfigured(502, {
|
assert.equal(
|
||||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
isHudSummaryUnconfigured(502, {
|
||||||
}), false);
|
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), 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 () => {
|
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) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
const response = await invokeRoute(hud, { method: 'POST' });
|
const response = await invokeRoute(hud, { method: 'POST' });
|
||||||
assert.equal(response.statusCode, 200);
|
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.equal(response.headers['cache-control'], 'no-store');
|
||||||
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||||
else process.env.OPENAI_API_KEY = previousKey;
|
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;
|
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',
|
id: 'flights',
|
||||||
name: 'Live Flights',
|
name: 'Live Flights',
|
||||||
enabled: true,
|
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',
|
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.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||||
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||||
assert.equal(context.feedProvenance.overall, 'stale');
|
assert.equal(context.feedProvenance.overall, 'stale');
|
||||||
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||||
assert.equal(hudTelemetryProvenanceTag([{
|
state: 'stale',
|
||||||
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
names: ['Live Flights'],
|
||||||
}], { now }), null);
|
});
|
||||||
|
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', () => {
|
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(
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
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/);
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
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.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||||
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
|
const { hudSummaryMatchesProvenance } =
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
|
await import('./hudSummaryResponse.js');
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
|
assert.equal(
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
|
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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -4,8 +4,11 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
checkCatalog,
|
checkCatalog,
|
||||||
extractMessages,
|
extractMessages,
|
||||||
|
findUnmarked,
|
||||||
readCatalog,
|
readCatalog,
|
||||||
shippedLocales,
|
shippedLocales,
|
||||||
|
unmarkedMarkup,
|
||||||
|
unmarkedScript,
|
||||||
} from '../../scripts/i18n.mjs';
|
} from '../../scripts/i18n.mjs';
|
||||||
import { LOCALES } from './locale.js';
|
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', () => {
|
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
|
||||||
const fixture = new Map([
|
const fixture = new Map([
|
||||||
['Saved {name}', { plural: false }],
|
['Saved {name}', { plural: false }],
|
||||||
@@ -55,3 +65,74 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', ()
|
|||||||
assert.deepEqual(result.stale, ['Removed']);
|
assert.deepEqual(result.stale, ['Removed']);
|
||||||
assert.equal(result.errors.length, 2);
|
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';"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -130,6 +130,12 @@ test('tn() picks English and Russian plural forms', () => {
|
|||||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
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', () => {
|
test('msg() returns its text unchanged', () => {
|
||||||
assert.equal(msg('Live Flights'), 'Live Flights');
|
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');
|
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', () => {
|
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
|
||||||
const storage = memoryStorage();
|
const storage = memoryStorage();
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
+48
-5
@@ -3,6 +3,7 @@
|
|||||||
// t('Save keys') → 'Сохранить ключи'
|
// t('Save keys') → 'Сохранить ключи'
|
||||||
// t('Cleared {name}', { name }) → named interpolation
|
// t('Cleared {name}', { name }) → named interpolation
|
||||||
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
|
// 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
|
// msg('Live Flights') → marks a string kept in data
|
||||||
// for a later t(); returns it as is
|
// for a later t(); returns it as is
|
||||||
//
|
//
|
||||||
@@ -76,11 +77,13 @@ function lookup(text) {
|
|||||||
/**
|
/**
|
||||||
* Translate English source text into the active locale.
|
* Translate English source text into the active locale.
|
||||||
* @param {string} text English text, e.g. 'Hold Space to talk'
|
* @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}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function t(text, values) {
|
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);
|
return interpolate(typeof entry === 'string' ? entry : text, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +109,18 @@ export function tn(count, one, other, values) {
|
|||||||
return interpolate(pattern, { ...values, count });
|
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(). */
|
/** Mark English text that is stored in data and translated later by t(). */
|
||||||
export function msg(text) {
|
export function msg(text) {
|
||||||
return text;
|
return text;
|
||||||
@@ -149,6 +164,32 @@ export function formatDate(value, options) {
|
|||||||
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
|
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.
|
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
* 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
|
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||||
* and stripping ?lang from the search string, so the override is one-shot.
|
* 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.
|
* @returns {boolean} true when a reload was triggered.
|
||||||
*/
|
*/
|
||||||
export function persistLocaleAndReload(
|
export function persistLocaleAndReload(
|
||||||
@@ -221,12 +262,14 @@ export function persistLocaleAndReload(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||||
writeStoredLocale(normalized, storage);
|
const stored = writeStoredLocale(normalized, storage);
|
||||||
if (!location || typeof location.assign !== 'function') return false;
|
if (!location || typeof location.assign !== 'function') return false;
|
||||||
let target = location.href;
|
let target = location.href;
|
||||||
try {
|
try {
|
||||||
|
// Without storage the choice only survives in the URL, so keep it there.
|
||||||
const url = new URL(location.href);
|
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;
|
target = url.href;
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the current href.
|
// Keep the current href.
|
||||||
|
|||||||
+1200
-76
File diff suppressed because it is too large.
Load diff
+1222
-77
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -128,12 +128,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
|
|||||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||||
state.textContent =
|
state.textContent =
|
||||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
mode === 'oauth'
|
||||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT 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 =
|
toggle.title =
|
||||||
mode === 'oauth'
|
mode === 'oauth'
|
||||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
? t('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 the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
||||||
|
);
|
||||||
// Keep the control focusable while the delegated handler blocks repeats.
|
// Keep the control focusable while the delegated handler blocks repeats.
|
||||||
toggle.setAttribute('aria-disabled', String(busy));
|
toggle.setAttribute('aria-disabled', String(busy));
|
||||||
}
|
}
|
||||||
@@ -153,21 +158,22 @@ function buildRow(documentRef, key) {
|
|||||||
led.className = 'key-setup-led';
|
led.className = 'key-setup-led';
|
||||||
led.setAttribute('aria-hidden', 'true');
|
led.setAttribute('aria-hidden', 'true');
|
||||||
const title = documentRef.createElement('strong');
|
const title = documentRef.createElement('strong');
|
||||||
title.textContent = key.title;
|
title.textContent = key.title; // i18n-ignore: provider product name
|
||||||
const tier = documentRef.createElement('span');
|
const tier = documentRef.createElement('span');
|
||||||
tier.className = 'key-setup-tier';
|
tier.className = 'key-setup-tier';
|
||||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||||
tier.title =
|
tier.title =
|
||||||
key.tier === 'metered'
|
key.tier === 'metered'
|
||||||
? 'Metered — a billing-enabled account'
|
? t('Metered — a billing-enabled account')
|
||||||
: 'Free key — register, paste, done';
|
: t('Free key — register, paste, done');
|
||||||
head.append(led, title, tier);
|
head.append(led, title, tier);
|
||||||
if (key.clientExposed) {
|
if (key.clientExposed) {
|
||||||
const exposed = documentRef.createElement('span');
|
const exposed = documentRef.createElement('span');
|
||||||
exposed.className = 'key-setup-exposed';
|
exposed.className = 'key-setup-exposed';
|
||||||
exposed.textContent = 'browser-side';
|
exposed.textContent = t('browser-side');
|
||||||
exposed.title =
|
exposed.title = t(
|
||||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
||||||
|
);
|
||||||
head.append(exposed);
|
head.append(exposed);
|
||||||
}
|
}
|
||||||
if (external) {
|
if (external) {
|
||||||
@@ -175,9 +181,10 @@ function buildRow(documentRef, key) {
|
|||||||
// facts this panel reports, never values it rewrites or deletes.
|
// facts this panel reports, never values it rewrites or deletes.
|
||||||
const badge = documentRef.createElement('span');
|
const badge = documentRef.createElement('span');
|
||||||
badge.className = 'key-setup-external';
|
badge.className = 'key-setup-external';
|
||||||
badge.textContent = 'configured externally';
|
badge.textContent = t('configured externally');
|
||||||
badge.title =
|
badge.title = t(
|
||||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
||||||
|
);
|
||||||
head.append(badge);
|
head.append(badge);
|
||||||
}
|
}
|
||||||
const get = documentRef.createElement('a');
|
const get = documentRef.createElement('a');
|
||||||
@@ -185,7 +192,7 @@ function buildRow(documentRef, key) {
|
|||||||
get.href = key.getUrl;
|
get.href = key.getUrl;
|
||||||
get.target = '_blank';
|
get.target = '_blank';
|
||||||
get.rel = 'noopener noreferrer';
|
get.rel = 'noopener noreferrer';
|
||||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
||||||
head.append(get);
|
head.append(get);
|
||||||
|
|
||||||
const unlocks = documentRef.createElement('p');
|
const unlocks = documentRef.createElement('p');
|
||||||
@@ -206,8 +213,8 @@ function buildRow(documentRef, key) {
|
|||||||
input.dataset.envVar = envVar;
|
input.dataset.envVar = envVar;
|
||||||
input.setAttribute('aria-label', envVar);
|
input.setAttribute('aria-label', envVar);
|
||||||
input.placeholder = key.set
|
input.placeholder = key.set
|
||||||
? `${envVar} saved — paste to replace`
|
? t('{envVar} saved — paste to replace', { envVar })
|
||||||
: `paste ${envVar}`;
|
: t('paste {envVar}', { envVar });
|
||||||
fields.append(input);
|
fields.append(input);
|
||||||
}
|
}
|
||||||
if (key.managed === 'file') {
|
if (key.managed === 'file') {
|
||||||
@@ -328,7 +335,9 @@ export async function initKeySetup({
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
chipLabel.textContent = keySetupChipLabel(status);
|
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);
|
chip.setAttribute('aria-label', chip.title);
|
||||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
// 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.
|
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||||
|
|||||||
+10
-32
@@ -39,7 +39,7 @@ const msg = (text) => text;
|
|||||||
export const KEY_SETUP_KEYS = Object.freeze([
|
export const KEY_SETUP_KEYS = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'google-maps',
|
id: 'google-maps',
|
||||||
title: 'GOOGLE MAPS',
|
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
|
||||||
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
|
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
|
||||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||||
@@ -48,7 +48,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'google-maps-server',
|
id: 'google-maps-server',
|
||||||
title: 'GOOGLE MAPS — SERVER',
|
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
|
||||||
unlocks: msg(
|
unlocks: msg(
|
||||||
'Places context + Street View fallback; optional separate key',
|
'Places context + Street View fallback; optional separate key',
|
||||||
),
|
),
|
||||||
@@ -60,7 +60,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
title: 'OPENAI',
|
title: 'OPENAI', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||||
getUrl: 'https://platform.openai.com/api-keys',
|
getUrl: 'https://platform.openai.com/api-keys',
|
||||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||||
@@ -68,7 +68,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'aisstream',
|
id: 'aisstream',
|
||||||
title: 'AISSTREAM',
|
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Live ships, worldwide'),
|
unlocks: msg('Live ships, worldwide'),
|
||||||
getUrl: 'https://aisstream.io',
|
getUrl: 'https://aisstream.io',
|
||||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||||
@@ -76,7 +76,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'firms',
|
id: 'firms',
|
||||||
title: 'NASA FIRMS',
|
title: 'NASA FIRMS', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Live active-fire detections'),
|
unlocks: msg('Live active-fire detections'),
|
||||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||||
@@ -84,7 +84,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'tomtom',
|
id: 'tomtom',
|
||||||
title: 'TOMTOM',
|
title: 'TOMTOM', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||||
getUrl: 'https://my.tomtom.com/keys',
|
getUrl: 'https://my.tomtom.com/keys',
|
||||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||||
@@ -92,7 +92,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'cesium-ion',
|
id: 'cesium-ion',
|
||||||
title: 'CESIUM ION',
|
title: 'CESIUM ION', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Bing imagery map stacks + world terrain'),
|
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||||
getUrl: 'https://ion.cesium.com/tokens',
|
getUrl: 'https://ion.cesium.com/tokens',
|
||||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||||
@@ -101,7 +101,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'opensky',
|
id: 'opensky',
|
||||||
title: 'OPENSKY',
|
title: 'OPENSKY', // i18n-ignore: provider product name
|
||||||
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||||
getUrl: 'https://opensky-network.org',
|
getUrl: 'https://opensky-network.org',
|
||||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||||
@@ -109,7 +109,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'mapillary',
|
id: 'mapillary',
|
||||||
title: 'MAPILLARY',
|
title: 'MAPILLARY', // i18n-ignore: provider product name
|
||||||
unlocks: msg(
|
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',
|
'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({
|
Object.freeze({
|
||||||
id: 'launch-library',
|
id: 'launch-library',
|
||||||
title: 'LAUNCH LIBRARY',
|
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||||
unlocks: msg('Higher space-missions request allowance'),
|
unlocks: msg('Higher space-missions request allowance'),
|
||||||
getUrl: 'https://thespacedevs.com',
|
getUrl: 'https://thespacedevs.com',
|
||||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||||
@@ -394,28 +394,6 @@ export function keySetupWithAlternatives(requirement, alternatives = []) {
|
|||||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
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
|
* Decide whether a live provider value belongs to a source outside the store
|
||||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||||
|
|||||||
+33
-19
@@ -18,6 +18,7 @@ import {
|
|||||||
alprCreditMarkup,
|
alprCreditMarkup,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
import { createAlprPresentation } from './presentation.js';
|
import { createAlprPresentation } from './presentation.js';
|
||||||
|
import { t, tn, msg } from '../../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Own one layer's requests, records, display and viewer subscriptions.
|
* Own one layer's requests, records, display and viewer subscriptions.
|
||||||
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
id: LAYER_ID,
|
id: LAYER_ID,
|
||||||
name: 'ALPR Cameras',
|
name: 'ALPR Cameras',
|
||||||
icon: '📷',
|
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,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'find-camera',
|
id: 'find-camera',
|
||||||
label: 'SHOW NEAREST',
|
label: t('SHOW NEAREST'),
|
||||||
title: state.viewer?.trackedEntity
|
title: state.viewer?.trackedEntity
|
||||||
? 'Stop following the current object before navigating to a camera'
|
? t(
|
||||||
: 'Move to the nearest loaded camera and show its details',
|
'Stop following the current object before navigating to a camera',
|
||||||
|
)
|
||||||
|
: t('Move to the nearest loaded camera and show its details'),
|
||||||
disabled:
|
disabled:
|
||||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||||
onClick: focusNearest,
|
onClick: focusNearest,
|
||||||
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
],
|
],
|
||||||
legend: [
|
legend: [
|
||||||
{
|
{
|
||||||
label: 'Camera badges',
|
label: t('Camera badges'),
|
||||||
color: ALPR_COLOR,
|
color: ALPR_COLOR,
|
||||||
count,
|
count,
|
||||||
blurb:
|
blurb: t(
|
||||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
'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,
|
count: state.dataSource?.entities.values.length || 0,
|
||||||
countLabel:
|
countLabel:
|
||||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
? Number.isInteger(state.onScreen)
|
||||||
Number.isInteger(state.onScreen)
|
? tn(
|
||||||
? ` · ${state.onScreen} on screen`
|
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),
|
noCoverage: Boolean(state.noCoverage),
|
||||||
lastUpdate: state.lastUpdate,
|
lastUpdate: state.lastUpdate,
|
||||||
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
: 0,
|
: 0,
|
||||||
loadingLabel: state.loading
|
loadingLabel: state.loading
|
||||||
? state.retrying
|
? state.retrying
|
||||||
? 'retrying mapped ALPR cameras'
|
? t('retrying mapped ALPR cameras')
|
||||||
: 'loading mapped ALPR cameras'
|
: t('loading mapped ALPR cameras')
|
||||||
: state.noCoverage
|
: 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'
|
: 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
|
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
|
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)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MARKER_SCALE_BY_DISTANCE,
|
MARKER_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { boxContains } from './model.js';
|
import { boxContains } from './model.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import { createAlprFloorResolver } from './floors.js';
|
import { createAlprFloorResolver } from './floors.js';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './overlay.js';
|
||||||
import {
|
import {
|
||||||
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
layerName: 'ALPR Cameras',
|
layerName: 'ALPR Cameras',
|
||||||
source:
|
source:
|
||||||
source.attribution?.description || source.label || 'Camera source',
|
source.attribution?.description || source.label || 'Camera source',
|
||||||
label: 'ALPR camera',
|
label: t('ALPR camera'),
|
||||||
latitude: record.latitude,
|
latitude: record.latitude,
|
||||||
longitude: record.longitude,
|
longitude: record.longitude,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
} from '../../sources/vectorTiles.js';
|
} from '../../sources/vectorTiles.js';
|
||||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||||
import { decodeAlprTile } from './tileRecords.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,
|
* 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();
|
for (const source of sources) source.clear();
|
||||||
precision.clear();
|
precision.clear();
|
||||||
},
|
},
|
||||||
label: 'OpenStreetMap · community mapped',
|
label: msg('OpenStreetMap · community mapped'),
|
||||||
attribution: {
|
attribution: {
|
||||||
name: 'OpenStreetMap',
|
name: 'OpenStreetMap',
|
||||||
description: '© OpenStreetMap contributors, ODbL',
|
description: '© OpenStreetMap contributors, ODbL',
|
||||||
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetch: fetchAlprNodes,
|
fetch: fetchAlprNodes,
|
||||||
label: 'OpenStreetMap · community mapped',
|
label: msg('OpenStreetMap · community mapped'),
|
||||||
attribution: {
|
attribution: {
|
||||||
name: 'OpenStreetMap',
|
name: 'OpenStreetMap',
|
||||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
formatAwarenessLabel,
|
formatAwarenessLabel,
|
||||||
formatAwarenessDistance,
|
|
||||||
AWARENESS_RADIUS_M,
|
AWARENESS_RADIUS_M,
|
||||||
} from '../../data/militaryAwarenessEngine.js';
|
} 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';
|
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||||
|
|
||||||
/** Format a cohort total without presenting a retained cap as exact. */
|
/** Format a cohort total without presenting a retained cap as exact. */
|
||||||
@@ -15,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
|
|||||||
: count;
|
: 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 }) {
|
export function createPanel({ state: layerState, services, parts, source }) {
|
||||||
function ensurePanel() {
|
function ensurePanel() {
|
||||||
if (layerState.panel) return layerState.panel;
|
if (layerState.panel) return layerState.panel;
|
||||||
@@ -71,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function rowHtml(cohort) {
|
function rowHtml(cohort) {
|
||||||
const summary = cohort.summary;
|
const summary = cohort.summary;
|
||||||
const count = formatAwarenessCount(summary);
|
const count = presentAwarenessCount(summary);
|
||||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
const nearest = summary.nearest
|
const nearest = summary.nearest
|
||||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||||
@@ -79,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
const label = formatAwarenessLabel(item);
|
const label = formatAwarenessLabel(item);
|
||||||
const targetId = item.icao24 || item.mmsi || item.id;
|
const targetId = item.icao24 || item.mmsi || item.id;
|
||||||
if (!targetId) {
|
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;
|
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('');
|
.join('');
|
||||||
const pageCount = Math.max(
|
const pageCount = Math.max(
|
||||||
@@ -96,7 +123,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
<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>` : ''}
|
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
@@ -176,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function namedAreasHtml(names) {
|
function namedAreasHtml(names) {
|
||||||
return names?.length
|
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();
|
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||||
const panel = ensurePanel();
|
const panel = ensurePanel();
|
||||||
const { subject, cohorts } = layerState.results;
|
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)}
|
${namedAreasHtml(subject.memberNames)}
|
||||||
${navigationControlsHtml()}
|
${navigationControlsHtml()}
|
||||||
${cohorts.map(rowHtml).join('')}
|
${cohorts.map(rowHtml).join('')}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
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';
|
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||||
|
|
||||||
export function createQueries({ state: layerState, services, parts, source }) {
|
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.
|
// A subject window is its own bounded area: name it instead of the viewport.
|
||||||
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
||||||
const km = Math.round(coverage.radiusM / 1000);
|
const km = Math.round(coverage.radiusM / 1000);
|
||||||
return {
|
return withReason(
|
||||||
...summary,
|
summary,
|
||||||
reason: summary.count
|
summary.count
|
||||||
? `mapped matches within ${km} km of the subject`
|
? msg('mapped matches within {km} km of the subject')
|
||||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
: msg('none mapped within {km} km; not a complete 250 km survey'),
|
||||||
};
|
{ km },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return withReason(
|
||||||
...summary,
|
summary,
|
||||||
reason: summary.count
|
summary.count
|
||||||
? 'mapped matches from the loaded viewport'
|
? msg('mapped matches from the loaded viewport')
|
||||||
: 'viewport feed is not a complete 250 km survey',
|
: msg('viewport feed is not a complete 250 km survey'),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+18
-18
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'nyc-midtown-w',
|
id: 'nyc-midtown-w',
|
||||||
cityId: 'nyc',
|
cityId: 'nyc',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'Midtown West @ 34th',
|
label: 'Midtown West @ 34th', // i18n-ignore: camera name
|
||||||
offsetNorthM: 120,
|
offsetNorthM: 120,
|
||||||
offsetEastM: -70,
|
offsetEastM: -70,
|
||||||
headingDeg: 206,
|
headingDeg: 206,
|
||||||
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'nyc-wtc-n',
|
id: 'nyc-wtc-n',
|
||||||
cityId: 'nyc',
|
cityId: 'nyc',
|
||||||
poiIndex: 2,
|
poiIndex: 2,
|
||||||
label: 'WTC North Plaza',
|
label: 'WTC North Plaza', // i18n-ignore: camera name
|
||||||
offsetNorthM: 95,
|
offsetNorthM: 95,
|
||||||
offsetEastM: 34,
|
offsetEastM: 34,
|
||||||
headingDeg: 164,
|
headingDeg: 164,
|
||||||
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'nyc-times-square-ne',
|
id: 'nyc-times-square-ne',
|
||||||
cityId: 'nyc',
|
cityId: 'nyc',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'Times Sq Northeast',
|
label: 'Times Sq Northeast', // i18n-ignore: camera name
|
||||||
offsetNorthM: 230,
|
offsetNorthM: 230,
|
||||||
offsetEastM: 120,
|
offsetEastM: 120,
|
||||||
headingDeg: 218,
|
headingDeg: 218,
|
||||||
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'sf-market-5th',
|
id: 'sf-market-5th',
|
||||||
cityId: 'sf',
|
cityId: 'sf',
|
||||||
poiIndex: 2,
|
poiIndex: 2,
|
||||||
label: 'Market & 5th',
|
label: 'Market & 5th', // i18n-ignore: camera name
|
||||||
offsetNorthM: -160,
|
offsetNorthM: -160,
|
||||||
offsetEastM: 80,
|
offsetEastM: 80,
|
||||||
headingDeg: 320,
|
headingDeg: 320,
|
||||||
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'sf-financial-district',
|
id: 'sf-financial-district',
|
||||||
cityId: 'sf',
|
cityId: 'sf',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'SF Financial Core',
|
label: 'SF Financial Core', // i18n-ignore: camera name
|
||||||
offsetNorthM: 110,
|
offsetNorthM: 110,
|
||||||
offsetEastM: 52,
|
offsetEastM: 52,
|
||||||
headingDeg: 205,
|
headingDeg: 205,
|
||||||
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'tokyo-shibuya-scramble',
|
id: 'tokyo-shibuya-scramble',
|
||||||
cityId: 'tokyo',
|
cityId: 'tokyo',
|
||||||
poiIndex: 4,
|
poiIndex: 4,
|
||||||
label: 'Shibuya Crossing',
|
label: 'Shibuya Crossing', // i18n-ignore: camera name
|
||||||
offsetNorthM: 180,
|
offsetNorthM: 180,
|
||||||
offsetEastM: 46,
|
offsetEastM: 46,
|
||||||
headingDeg: 18,
|
headingDeg: 18,
|
||||||
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'tokyo-ginza-core',
|
id: 'tokyo-ginza-core',
|
||||||
cityId: 'tokyo',
|
cityId: 'tokyo',
|
||||||
poiIndex: 0,
|
poiIndex: 0,
|
||||||
label: 'Ginza Core',
|
label: 'Ginza Core', // i18n-ignore: camera name
|
||||||
offsetNorthM: -180,
|
offsetNorthM: -180,
|
||||||
offsetEastM: 150,
|
offsetEastM: 150,
|
||||||
headingDeg: 245,
|
headingDeg: 245,
|
||||||
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'tokyo-asakusa-n',
|
id: 'tokyo-asakusa-n',
|
||||||
cityId: 'tokyo',
|
cityId: 'tokyo',
|
||||||
poiIndex: 3,
|
poiIndex: 3,
|
||||||
label: 'Asakusa North Gate',
|
label: 'Asakusa North Gate', // i18n-ignore: camera name
|
||||||
offsetNorthM: 110,
|
offsetNorthM: 110,
|
||||||
offsetEastM: -65,
|
offsetEastM: -65,
|
||||||
headingDeg: 192,
|
headingDeg: 192,
|
||||||
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'london-city-a1',
|
id: 'london-city-a1',
|
||||||
cityId: 'london',
|
cityId: 'london',
|
||||||
poiIndex: 4,
|
poiIndex: 4,
|
||||||
label: 'City Cluster A1',
|
label: 'City Cluster A1', // i18n-ignore: camera name
|
||||||
offsetNorthM: 80,
|
offsetNorthM: 80,
|
||||||
offsetEastM: 65,
|
offsetEastM: 65,
|
||||||
headingDeg: 220,
|
headingDeg: 220,
|
||||||
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'london-soho-core',
|
id: 'london-soho-core',
|
||||||
cityId: 'london',
|
cityId: 'london',
|
||||||
poiIndex: 2,
|
poiIndex: 2,
|
||||||
label: 'Soho Core',
|
label: 'Soho Core', // i18n-ignore: camera name
|
||||||
offsetNorthM: 210,
|
offsetNorthM: 210,
|
||||||
offsetEastM: 120,
|
offsetEastM: 120,
|
||||||
headingDeg: 206,
|
headingDeg: 206,
|
||||||
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'paris-rivoli',
|
id: 'paris-rivoli',
|
||||||
cityId: 'paris',
|
cityId: 'paris',
|
||||||
poiIndex: 4,
|
poiIndex: 4,
|
||||||
label: 'Rue de Rivoli',
|
label: 'Rue de Rivoli', // i18n-ignore: camera name
|
||||||
offsetNorthM: 55,
|
offsetNorthM: 55,
|
||||||
offsetEastM: 85,
|
offsetEastM: 85,
|
||||||
headingDeg: 248,
|
headingDeg: 248,
|
||||||
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'paris-champs-n',
|
id: 'paris-champs-n',
|
||||||
cityId: 'paris',
|
cityId: 'paris',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'Champs-Élysées North',
|
label: 'Champs-Élysées North', // i18n-ignore: camera name
|
||||||
offsetNorthM: 130,
|
offsetNorthM: 130,
|
||||||
offsetEastM: -38,
|
offsetEastM: -38,
|
||||||
headingDeg: 175,
|
headingDeg: 175,
|
||||||
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'dc-mall-center',
|
id: 'dc-mall-center',
|
||||||
cityId: 'dc',
|
cityId: 'dc',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'National Mall Center',
|
label: 'National Mall Center', // i18n-ignore: camera name
|
||||||
offsetNorthM: 120,
|
offsetNorthM: 120,
|
||||||
offsetEastM: 20,
|
offsetEastM: 20,
|
||||||
headingDeg: 258,
|
headingDeg: 258,
|
||||||
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'dc-pentagon-s',
|
id: 'dc-pentagon-s',
|
||||||
cityId: 'dc',
|
cityId: 'dc',
|
||||||
poiIndex: 3,
|
poiIndex: 3,
|
||||||
label: 'Pentagon South',
|
label: 'Pentagon South', // i18n-ignore: camera name
|
||||||
offsetNorthM: -100,
|
offsetNorthM: -100,
|
||||||
offsetEastM: 92,
|
offsetEastM: 92,
|
||||||
headingDeg: 14,
|
headingDeg: 14,
|
||||||
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'dubai-difc-loop',
|
id: 'dubai-difc-loop',
|
||||||
cityId: 'dubai',
|
cityId: 'dubai',
|
||||||
poiIndex: 4,
|
poiIndex: 4,
|
||||||
label: 'DIFC Loop',
|
label: 'DIFC Loop', // i18n-ignore: camera name
|
||||||
offsetNorthM: 92,
|
offsetNorthM: 92,
|
||||||
offsetEastM: -45,
|
offsetEastM: -45,
|
||||||
headingDeg: 196,
|
headingDeg: 196,
|
||||||
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'dubai-downtown-east',
|
id: 'dubai-downtown-east',
|
||||||
cityId: 'dubai',
|
cityId: 'dubai',
|
||||||
poiIndex: 0,
|
poiIndex: 0,
|
||||||
label: 'Downtown East',
|
label: 'Downtown East', // i18n-ignore: camera name
|
||||||
offsetNorthM: -130,
|
offsetNorthM: -130,
|
||||||
offsetEastM: 190,
|
offsetEastM: 190,
|
||||||
headingDeg: 322,
|
headingDeg: 322,
|
||||||
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'austin-congress-s',
|
id: 'austin-congress-s',
|
||||||
cityId: 'austin',
|
cityId: 'austin',
|
||||||
poiIndex: 0,
|
poiIndex: 0,
|
||||||
label: 'Congress Southbound',
|
label: 'Congress Southbound', // i18n-ignore: camera name
|
||||||
offsetNorthM: -165,
|
offsetNorthM: -165,
|
||||||
offsetEastM: 40,
|
offsetEastM: 40,
|
||||||
headingDeg: 12,
|
headingDeg: 12,
|
||||||
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
|
|||||||
id: 'austin-downtown-west',
|
id: 'austin-downtown-west',
|
||||||
cityId: 'austin',
|
cityId: 'austin',
|
||||||
poiIndex: 1,
|
poiIndex: 1,
|
||||||
label: 'Downtown West',
|
label: 'Downtown West', // i18n-ignore: camera name
|
||||||
offsetNorthM: -120,
|
offsetNorthM: -120,
|
||||||
offsetEastM: -160,
|
offsetEastM: -160,
|
||||||
headingDeg: 120,
|
headingDeg: 120,
|
||||||
|
|||||||
@@ -14,23 +14,32 @@ import {
|
|||||||
CYCLONE_OVERLAY_SOURCE_ID,
|
CYCLONE_OVERLAY_SOURCE_ID,
|
||||||
cycloneStormIdFromEntryId,
|
cycloneStormIdFromEntryId,
|
||||||
} from './labels.js';
|
} 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) =>
|
const utc = (value) =>
|
||||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
value
|
||||||
const COVERAGE =
|
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
|
||||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
: t('Unavailable');
|
||||||
|
const COVERAGE = msg(
|
||||||
|
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||||
|
);
|
||||||
const CLASSIFICATION_NAMES = Object.freeze({
|
const CLASSIFICATION_NAMES = Object.freeze({
|
||||||
PTC: 'Potential tropical cyclone',
|
PTC: msg('Potential tropical cyclone'),
|
||||||
HU: 'Hurricane',
|
HU: msg('Hurricane'),
|
||||||
TS: 'Tropical storm',
|
TS: msg('Tropical storm'),
|
||||||
TD: 'Tropical depression',
|
TD: msg('Tropical depression'),
|
||||||
SS: 'Subtropical storm',
|
SS: msg('Subtropical storm'),
|
||||||
SD: 'Subtropical depression',
|
SD: msg('Subtropical depression'),
|
||||||
});
|
});
|
||||||
const classificationName = (code) =>
|
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) =>
|
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. */
|
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||||
export function createCyclonesLayer({
|
export function createCyclonesLayer({
|
||||||
@@ -316,41 +325,68 @@ export function createCyclonesLayer({
|
|||||||
const geometry =
|
const geometry =
|
||||||
storm &&
|
storm &&
|
||||||
(coherentCycloneGeometry(storm)
|
(coherentCycloneGeometry(storm)
|
||||||
? 'Track and cone match this advisory'
|
? t('Track and cone match this advisory')
|
||||||
: storm.geometryStatus === 'pending'
|
: storm.geometryStatus === 'pending'
|
||||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
? t('Track/cone awaiting advisory {number}', {
|
||||||
: 'Track/cone unavailable');
|
number: storm.advisoryNumber,
|
||||||
|
})
|
||||||
|
: t('Track/cone unavailable'));
|
||||||
const status =
|
const status =
|
||||||
error ||
|
error ||
|
||||||
(snapshot?.stale
|
(snapshot?.stale
|
||||||
? 'Cached advisory · stale source'
|
? t('Cached advisory · stale source')
|
||||||
: loading
|
: loading
|
||||||
? 'Loading advisories…'
|
? t('Loading advisories…')
|
||||||
: storm && !coherentCycloneGeometry(storm)
|
: storm && !coherentCycloneGeometry(storm)
|
||||||
? geometry
|
? geometry
|
||||||
: null);
|
: 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
|
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
|
: empty
|
||||||
? 'No active NHC/CPHC systems'
|
? t('No active NHC/CPHC systems')
|
||||||
: snapshot?.storms.length
|
: stormCount
|
||||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
? activeStorms
|
||||||
: loading
|
: loading
|
||||||
? 'Loading advisories…'
|
? t('Loading advisories…')
|
||||||
: 'Advisories unavailable';
|
: t('Advisories unavailable');
|
||||||
const controls = {
|
const controls = {
|
||||||
readout: true,
|
readout: true,
|
||||||
summary: {
|
summary: {
|
||||||
label: 'Cyclones · NHC / CPHC',
|
label: t('Cyclones · NHC / CPHC'),
|
||||||
coverage: 'Atlantic · E/C Pacific',
|
coverage: t('Atlantic · E/C Pacific'),
|
||||||
compact: snapshot?.storms.length
|
compact: stormCount
|
||||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
? storm
|
||||||
|
? t('{storms} · {name} selected', {
|
||||||
|
storms: activeStorms,
|
||||||
|
name: storm.name,
|
||||||
|
})
|
||||||
|
: activeStorms
|
||||||
: detail,
|
: detail,
|
||||||
actions: storm?.advisoryUrl
|
actions: storm?.advisoryUrl
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'advisory',
|
id: 'advisory',
|
||||||
label: 'Official advisory ↗',
|
label: t('Official advisory ↗'),
|
||||||
href: storm.advisoryUrl,
|
href: storm.advisoryUrl,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -360,12 +396,12 @@ export function createCyclonesLayer({
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'position',
|
id: 'position',
|
||||||
text: `Position as of ${utc(storm.positionAt)}`,
|
text: position,
|
||||||
muted: true,
|
muted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'intensity',
|
id: 'intensity',
|
||||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
text: intensity,
|
||||||
},
|
},
|
||||||
{ id: 'geometry', text: geometry, muted: true },
|
{ id: 'geometry', text: geometry, muted: true },
|
||||||
]
|
]
|
||||||
@@ -375,12 +411,12 @@ export function createCyclonesLayer({
|
|||||||
units: 'kt',
|
units: 'kt',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
|
||||||
items: (snapshot?.storms || []).map((item, index) => ({
|
items: (snapshot?.storms || []).map((item, index) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
ordinal: index + 1,
|
ordinal: index + 1,
|
||||||
lead: item.basin,
|
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,
|
active: item.id === selectedId,
|
||||||
params: { stormId: item.id, focus: true },
|
params: { stormId: item.id, focus: true },
|
||||||
})),
|
})),
|
||||||
@@ -388,15 +424,22 @@ export function createCyclonesLayer({
|
|||||||
chips: [],
|
chips: [],
|
||||||
legend: storm
|
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
|
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}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
|
||||||
infoTitle:
|
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.',
|
'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;
|
return controls;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
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, /not storm size/);
|
||||||
assert.match(controls.infoTitle, /follows the surface/);
|
assert.match(controls.infoTitle, /follows the surface/);
|
||||||
assert.equal(controls.list.items[1].params.focus, true);
|
assert.equal(controls.list.items[1].params.focus, true);
|
||||||
|
|||||||
+350
-53
@@ -17,10 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import { roadLabel } from '../../data/routeSteps.js';
|
||||||
formatRouteDistance,
|
import { t, msg, formatNumber } from '../../i18n/index.js';
|
||||||
formatRouteDuration,
|
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||||
} from '../../data/routeSteps.js';
|
|
||||||
|
|
||||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
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. */
|
/** Travel modes, keyed by the `/api/route` profile name. */
|
||||||
export const DIRECTIONS_MODES = Object.freeze({
|
export const DIRECTIONS_MODES = Object.freeze({
|
||||||
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
|
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
|
||||||
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
|
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
|
||||||
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
|
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
|
||||||
});
|
});
|
||||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
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). */
|
/** How often the row repaints the highlighted step while FLY is running (ms). */
|
||||||
export const FLIGHT_PROGRESS_MS = 250;
|
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)
|
// Pure helpers (exported for tests)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -107,32 +370,35 @@ export function directionsRowControls(state) {
|
|||||||
const routing = status === 'routing';
|
const routing = status === 'routing';
|
||||||
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
|
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
|
||||||
id: `mode-${id}`,
|
id: `mode-${id}`,
|
||||||
label: spec.chip,
|
label: t(spec.chip),
|
||||||
active: mode === id,
|
active: mode === id,
|
||||||
state: mode === id ? 'active' : 'idle',
|
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 },
|
params: { mode: id },
|
||||||
}));
|
}));
|
||||||
chips.push({
|
chips.push({
|
||||||
id: 'set-a',
|
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',
|
active: armed === 'a',
|
||||||
state: armed === 'a' ? 'active' : 'idle',
|
state: armed === 'a' ? 'active' : 'idle',
|
||||||
title:
|
title:
|
||||||
armed === 'a'
|
armed === 'a'
|
||||||
? 'Click a spot on the globe to place A (click again to cancel)'
|
? t('Click a spot on the globe to place A (click again to cancel)')
|
||||||
: 'Then click the globe to place the start',
|
: t('Then click the globe to place the start'),
|
||||||
params: { arm: armed === 'a' ? null : 'a' },
|
params: { arm: armed === 'a' ? null : 'a' },
|
||||||
});
|
});
|
||||||
chips.push({
|
chips.push({
|
||||||
id: 'set-b',
|
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',
|
active: armed === 'b',
|
||||||
state: armed === 'b' ? 'active' : 'idle',
|
state: armed === 'b' ? 'active' : 'idle',
|
||||||
title:
|
title:
|
||||||
armed === 'b'
|
armed === 'b'
|
||||||
? 'Click a spot on the globe to place B (click again to cancel)'
|
? t('Click a spot on the globe to place B (click again to cancel)')
|
||||||
: 'Then click the globe to place the destination',
|
: t('Then click the globe to place the destination'),
|
||||||
params: { arm: armed === 'b' ? null : 'b' },
|
params: { arm: armed === 'b' ? null : 'b' },
|
||||||
});
|
});
|
||||||
chips.push({
|
chips.push({
|
||||||
@@ -140,26 +406,28 @@ export function directionsRowControls(state) {
|
|||||||
label: '⇄',
|
label: '⇄',
|
||||||
disabled: !(a && b) || routing,
|
disabled: !(a && b) || routing,
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
title: 'Swap A and B',
|
title: t('Swap A and B'),
|
||||||
params: { swap: true },
|
params: { swap: true },
|
||||||
});
|
});
|
||||||
const flying = Number.isInteger(flightStep);
|
const flying = Number.isInteger(flightStep);
|
||||||
chips.push({
|
chips.push({
|
||||||
id: 'fly',
|
id: 'fly',
|
||||||
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
|
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
|
||||||
disabled: !route || routing,
|
disabled: !route || routing,
|
||||||
busy: routing || flying,
|
busy: routing || flying,
|
||||||
active: flying,
|
active: flying,
|
||||||
state: routing || flying ? 'loading' : 'idle',
|
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 },
|
params: { fly: true },
|
||||||
});
|
});
|
||||||
chips.push({
|
chips.push({
|
||||||
id: 'clear',
|
id: 'clear',
|
||||||
label: 'CLEAR',
|
label: t('CLEAR', { context: 'action' }),
|
||||||
disabled: !a && !b && !route,
|
disabled: !a && !b && !route,
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
title: 'Remove the route and both markers',
|
title: t('Remove the route and both markers'),
|
||||||
params: { clear: true },
|
params: { clear: true },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -179,8 +447,8 @@ export function directionsRowControls(state) {
|
|||||||
*/
|
*/
|
||||||
export const POINTER_TOOL_EXITS = Object.freeze({
|
export const POINTER_TOOL_EXITS = Object.freeze({
|
||||||
draw: Object.freeze({
|
draw: Object.freeze({
|
||||||
name: 'Draw',
|
name: msg('Draw'),
|
||||||
leave: 'press Escape twice to leave 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;
|
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
|
||||||
const endpoint = which === 'b' ? 'B' : 'A';
|
const endpoint = which === 'b' ? 'B' : 'A';
|
||||||
const known = POINTER_TOOL_EXITS[id];
|
const known = POINTER_TOOL_EXITS[id];
|
||||||
const name = known?.name || id;
|
if (!known)
|
||||||
const leave = known?.leave || `turn ${name} off`;
|
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||||
return `${name} is active — ${leave}, 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) => ({
|
const items = steps.map((step, index) => ({
|
||||||
id: `step-${index}`,
|
id: `step-${index}`,
|
||||||
ordinal: index + 1,
|
ordinal: index + 1,
|
||||||
lead: formatRouteDistance(step.distanceM) || '—',
|
lead: routeDistanceText(step.distanceM) || '—',
|
||||||
text: step.instruction,
|
text: directionsInstruction(step),
|
||||||
active: index === active,
|
active: index === active,
|
||||||
current: Number.isInteger(flightStep) && index === flightStep,
|
current: Number.isInteger(flightStep) && index === flightStep,
|
||||||
params: { step: index },
|
params: { step: index },
|
||||||
@@ -285,13 +560,15 @@ export function directionsStepList({
|
|||||||
id: 'step-truncated',
|
id: 'step-truncated',
|
||||||
ordinal: items.length + 1,
|
ordinal: items.length + 1,
|
||||||
lead: '',
|
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,
|
active: false,
|
||||||
current: false,
|
current: false,
|
||||||
disabled: true,
|
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,
|
armed,
|
||||||
pointerBlocked,
|
pointerBlocked,
|
||||||
} = state;
|
} = 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) {
|
if (pointerBlocked) {
|
||||||
return {
|
return {
|
||||||
count: route?.steps.length || 0,
|
count: route?.steps.length || 0,
|
||||||
lastUpdate,
|
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',
|
status: 'empty',
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
@@ -347,7 +625,7 @@ export function directionsStats(state) {
|
|||||||
lastUpdate,
|
lastUpdate,
|
||||||
error: null,
|
error: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
loadingLabel: 'Routing…',
|
loadingLabel: t('Routing…'),
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -355,7 +633,7 @@ export function directionsStats(state) {
|
|||||||
return {
|
return {
|
||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
error: error || 'No route found',
|
error: error || t('No route found'),
|
||||||
status: 'empty',
|
status: 'empty',
|
||||||
source,
|
source,
|
||||||
};
|
};
|
||||||
@@ -364,10 +642,17 @@ export function directionsStats(state) {
|
|||||||
// set (not only while loading), so the route summary and the placement
|
// set (not only while loading), so the route summary and the placement
|
||||||
// guidance ride on it; `coverage` carries the same text for stats readers.
|
// guidance ride on it; `coverage` carries the same text for stats readers.
|
||||||
if (route) {
|
if (route) {
|
||||||
const word = DIRECTIONS_MODES[mode]?.word || mode;
|
const spec = DIRECTIONS_MODES[mode];
|
||||||
const summary =
|
const word = spec ? t(spec.word) : mode;
|
||||||
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
|
const values = {
|
||||||
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
|
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 {
|
return {
|
||||||
count: route.steps.length,
|
count: route.steps.length,
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
@@ -378,10 +663,13 @@ export function directionsStats(state) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let coverage;
|
let coverage;
|
||||||
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
|
if (armed)
|
||||||
else if (a && !b) coverage = 'SET B, then click the globe';
|
coverage = t('Click the globe to place {endpoint}', {
|
||||||
else if (!a && b) coverage = 'SET A, then click the globe';
|
endpoint: armed.toUpperCase(),
|
||||||
else coverage = 'SET A, then click the globe';
|
});
|
||||||
|
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 {
|
return {
|
||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
@@ -403,14 +691,23 @@ export function directionsStepCopy(steps, index) {
|
|||||||
const step = steps[index];
|
const step = steps[index];
|
||||||
const details = [];
|
const details = [];
|
||||||
const leg = [];
|
const leg = [];
|
||||||
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
|
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
|
||||||
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
|
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
|
||||||
|
const position = { step: index + 1, total: steps.length };
|
||||||
details.push(
|
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];
|
const next = steps[index + 1];
|
||||||
if (next) details.push(`Then: ${next.instruction}`);
|
if (next)
|
||||||
return { title: step.instruction, details };
|
details.push(
|
||||||
|
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
|
||||||
|
);
|
||||||
|
return { title: directionsInstruction(step), details };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -819,10 +1116,10 @@ export function createDirectionsLayer({ services, source }) {
|
|||||||
_status = 'error';
|
_status = 'error';
|
||||||
_error =
|
_error =
|
||||||
payload?.error === 'no route found'
|
payload?.error === 'no route found'
|
||||||
? 'No route found between A and B'
|
? t('No route found between A and B')
|
||||||
: payload?.error
|
: payload?.error
|
||||||
? `Routing failed: ${payload.error}`
|
? t('Routing failed: {error}', { error: payload.error })
|
||||||
: 'No route found between A and B';
|
: t('No route found between A and B');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_route = route;
|
_route = route;
|
||||||
@@ -839,10 +1136,10 @@ export function createDirectionsLayer({ services, source }) {
|
|||||||
// happened instead.
|
// happened instead.
|
||||||
_error =
|
_error =
|
||||||
error?.name === 'AbortError'
|
error?.name === 'AbortError'
|
||||||
? 'Routing timed out'
|
? t('Routing timed out')
|
||||||
: error?.name === 'TypeError'
|
: error?.name === 'TypeError'
|
||||||
? 'Routing unavailable — no response from the routing service'
|
? t('Routing unavailable — no response from the routing service')
|
||||||
: error?.message || 'Routing unavailable';
|
: error?.message || t('Routing unavailable');
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (_routeAbort === controller) _routeAbort = null;
|
if (_routeAbort === controller) _routeAbort = null;
|
||||||
@@ -1037,7 +1334,7 @@ export function createDirectionsLayer({ services, source }) {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
type: 'route',
|
type: 'route',
|
||||||
label: 'Directions',
|
label: t('Directions'),
|
||||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+67
-10
@@ -1,5 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
satelliteShortName,
|
satelliteShortName,
|
||||||
fireDetectionKey,
|
fireDetectionKey,
|
||||||
@@ -332,11 +332,11 @@ export function createModel({
|
|||||||
function buildSelectedFireCard(fire, nowMs) {
|
function buildSelectedFireCard(fire, nowMs) {
|
||||||
const meta = [
|
const meta = [
|
||||||
t('{value} conf', {
|
t('{value} conf', {
|
||||||
value: confidenceBucket(fire.confidence),
|
value: confidenceLabel(confidenceBucket(fire.confidence)),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
if (fire.acqMs > 0) {
|
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 }));
|
if (age) meta.push(t('{value} ago', { value: age }));
|
||||||
}
|
}
|
||||||
const sat = satelliteShortName(fire.satellite);
|
const sat = satelliteShortName(fire.satellite);
|
||||||
@@ -351,10 +351,10 @@ export function createModel({
|
|||||||
cullPosition: fireCullPosition(fire),
|
cullPosition: fireCullPosition(fire),
|
||||||
gapPx: frpPixelSize(fire.frp),
|
gapPx: frpPixelSize(fire.frp),
|
||||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||||
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
|
title: t('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||||
details: [
|
details: [
|
||||||
meta.join(' · '),
|
meta.join(' · '),
|
||||||
formatLatLon(fire.lat, fire.lon) +
|
formatLatLonLabel(fire.lat, fire.lon) +
|
||||||
(fire.night ? ` · ${t('NIGHT')}` : ''),
|
(fire.night ? ` · ${t('NIGHT')}` : ''),
|
||||||
],
|
],
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -374,9 +374,9 @@ export function createModel({
|
|||||||
|
|
||||||
function buildFireCard(candidate, nowMs) {
|
function buildFireCard(candidate, nowMs) {
|
||||||
const fire = candidate.fire;
|
const fire = candidate.fire;
|
||||||
const meta = [confidenceBucket(fire.confidence)];
|
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
|
||||||
if (fire.acqMs > 0) {
|
if (fire.acqMs > 0) {
|
||||||
const age = formatAge(nowMs - fire.acqMs);
|
const age = formatAgeLabel(nowMs - fire.acqMs);
|
||||||
if (age) meta.push(age);
|
if (age) meta.push(age);
|
||||||
}
|
}
|
||||||
const sat = satelliteShortName(fire.satellite) || fire.sensor;
|
const sat = satelliteShortName(fire.satellite) || fire.sensor;
|
||||||
@@ -388,7 +388,7 @@ export function createModel({
|
|||||||
cullPosition: candidate.cullPosition || candidate.position,
|
cullPosition: candidate.cullPosition || candidate.position,
|
||||||
gapPx: frpPixelSize(fire.frp),
|
gapPx: frpPixelSize(fire.frp),
|
||||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||||
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
|
title: t('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||||
details: [meta.join(' · ')],
|
details: [meta.join(' · ')],
|
||||||
selected: false,
|
selected: false,
|
||||||
priority: Number(fire.frp) || 0,
|
priority: Number(fire.frp) || 0,
|
||||||
@@ -407,9 +407,9 @@ export function createModel({
|
|||||||
function buildCellCard(candidate, nowMs) {
|
function buildCellCard(candidate, nowMs) {
|
||||||
const cell = candidate.cell;
|
const cell = candidate.cell;
|
||||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
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) {
|
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 }));
|
if (age) parts.push(t('new {value}', { value: age }));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -483,6 +483,37 @@ export function createModel({
|
|||||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
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. */
|
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||||
|
|
||||||
function formatAge(deltaMs) {
|
function formatAge(deltaMs) {
|
||||||
@@ -503,6 +534,32 @@ export function createModel({
|
|||||||
return t('{count}h ago', { count: Math.round(minutes / 60) });
|
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. */
|
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||||
|
|
||||||
function confidenceBucket(confidence) {
|
function confidenceBucket(confidence) {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export function createQueries({
|
|||||||
if (!fire) return null;
|
if (!fire) return null;
|
||||||
return {
|
return {
|
||||||
id: fireDetectionKey(fire),
|
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,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
frp: fire.frp,
|
frp: fire.frp,
|
||||||
@@ -115,7 +117,9 @@ export function createQueries({
|
|||||||
latitude: strongest.lat,
|
latitude: strongest.lat,
|
||||||
longitude: strongest.lon,
|
longitude: strongest.lon,
|
||||||
frp: strongest.frp,
|
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,6 +5,7 @@ import {
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
import { CONTEXT_TOP_N } from './policy.js';
|
import { CONTEXT_TOP_N } from './policy.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export function createSelection({
|
export function createSelection({
|
||||||
layerState,
|
layerState,
|
||||||
@@ -80,7 +81,7 @@ export function createSelection({
|
|||||||
requestWorldFocus({
|
requestWorldFocus({
|
||||||
kind: 'fire',
|
kind: 'fire',
|
||||||
id: fireDetectionKey(fire),
|
id: fireDetectionKey(fire),
|
||||||
label: 'FIRE',
|
label: t('FIRE'),
|
||||||
position: components.model.firePosition(fire),
|
position: components.model.firePosition(fire),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -192,7 +193,9 @@ export function createSelection({
|
|||||||
layerName: name,
|
layerName: name,
|
||||||
source: 'NASA FIRMS',
|
source: 'NASA FIRMS',
|
||||||
dataSource: layerState._dataSource,
|
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,
|
latitude: fire.lat,
|
||||||
longitude: fire.lon,
|
longitude: fire.lon,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
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 { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||||
import {
|
import {
|
||||||
@@ -752,8 +753,15 @@ export function createTracking({
|
|||||||
// the tail number rather than raw hex.
|
// the tail number rather than raw hex.
|
||||||
const cs = parts.queries._contactLabel(icao24, info);
|
const cs = parts.queries._contactLabel(icao24, info);
|
||||||
const altFt = Math.round((info.altitude || 0) * 3.28084);
|
const altFt = Math.round((info.altitude || 0) * 3.28084);
|
||||||
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
|
// Flight levels are an aviation code; feet and knots read in the active
|
||||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
// 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 =
|
const stale =
|
||||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||||
? t('STALE')
|
? t('STALE')
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { installationClassLabel } from './classLabel.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
MILITARY_LABEL_CAP,
|
MILITARY_LABEL_CAP,
|
||||||
@@ -177,9 +178,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
|||||||
marker.entry.selected = record.id === state.selectedId;
|
marker.entry.selected = record.id === state.selectedId;
|
||||||
marker.entry.details = marker.entry.selected
|
marker.entry.details = marker.entry.selected
|
||||||
? [
|
? [
|
||||||
String(record.class || 'installation')
|
installationClassLabel(record.class),
|
||||||
.replaceAll('_', ' ')
|
|
||||||
.toUpperCase(),
|
|
||||||
...(record.memberNames || []).slice(0, 3),
|
...(record.memberNames || []).slice(0, 3),
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { installationClassLabel } from './classLabel.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||||
@@ -273,9 +274,7 @@ export function createRendering({
|
|||||||
entity.gevLabelModel = {
|
entity.gevLabelModel = {
|
||||||
title: record.name || t('MAPPED INSTALLATION'),
|
title: record.name || t('MAPPED INSTALLATION'),
|
||||||
details: [
|
details: [
|
||||||
String(record.class || 'installation')
|
installationClassLabel(record.class),
|
||||||
.replaceAll('_', ' ')
|
|
||||||
.toUpperCase(),
|
|
||||||
...(record.memberNames || []).slice(0, 3),
|
...(record.memberNames || []).slice(0, 3),
|
||||||
],
|
],
|
||||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||||
layerState._missionPanel.setAttribute(
|
layerState._missionPanel.setAttribute(
|
||||||
'aria-label',
|
'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.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
|
layerState._missionPanel
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
inciwebNodeId,
|
inciwebNodeId,
|
||||||
isCurrentPublication,
|
isCurrentPublication,
|
||||||
} from './inciweb.js';
|
} from './inciweb.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||||
export { createWfigsPerimeterSource } from './source.js';
|
export { createWfigsPerimeterSource } from './source.js';
|
||||||
export * from './cards.js';
|
export * from './cards.js';
|
||||||
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
|
|||||||
|
|
||||||
getRowControls() {
|
getRowControls() {
|
||||||
const bands = [
|
const bands = [
|
||||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
|
||||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
{ label: t('Under 50% contained'), color: containmentAccent(1) },
|
||||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
{ label: t('50–99% contained'), color: containmentAccent(50) },
|
||||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
{ label: t('Fully contained'), color: containmentAccent(100) },
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
chips: [],
|
chips: [],
|
||||||
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
|
|||||||
).length,
|
).length,
|
||||||
...(index === 0
|
...(index === 0
|
||||||
? {
|
? {
|
||||||
blurb:
|
blurb: t(
|
||||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function createCategories({
|
|||||||
function buildRadioCategories(stations) {
|
function buildRadioCategories(stations) {
|
||||||
const rows = Array.isArray(stations) ? stations : [];
|
const rows = Array.isArray(stations) ? stations : [];
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: t('All') },
|
{ id: 'all', label: t('All', { context: 'radio tag' }) },
|
||||||
{ id: 'news', label: t('News') },
|
{ id: 'news', label: t('News') },
|
||||||
{ id: 'talk', label: t('Talk') },
|
{ id: 'talk', label: t('Talk') },
|
||||||
{ id: 'weather', label: t('Weather / Emergency') },
|
{ id: 'weather', label: t('Weather / Emergency') },
|
||||||
@@ -89,7 +89,7 @@ export function createCategories({
|
|||||||
for (const [genre, label] of MUSIC_GENRES) {
|
for (const [genre, label] of MUSIC_GENRES) {
|
||||||
const id = `genre:${genre}`;
|
const id = `genre:${genre}`;
|
||||||
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
||||||
categories.push({ id, label });
|
categories.push({ id, label: t(label) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
categories.push({ id: 'other', label: t('Other') });
|
categories.push({ id: 'other', label: t('Other') });
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
RADIO_CATEGORY_COLORS,
|
RADIO_CATEGORY_COLORS,
|
||||||
RADIO_CLUSTER_LABELS,
|
|
||||||
RADIO_MARKER_CATEGORY_ORDER,
|
RADIO_MARKER_CATEGORY_ORDER,
|
||||||
} from './policy.js';
|
} 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 }) {
|
export function createModel({ state: layerState, services, parts, source }) {
|
||||||
/** Return the shared CSS color for a canonical or detected-genre category. */
|
/** 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) {
|
function radioClusterBadgeText(categoryId = 'other', count = 0) {
|
||||||
const normalized = String(categoryId || 'other');
|
const normalized = String(categoryId || 'other');
|
||||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
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));
|
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
|
||||||
return `${stationCount} ${label}`;
|
return `${stationCount} ${label}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-36
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
|
|||||||
|
|
||||||
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
|
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([
|
export const MUSIC_GENRES = Object.freeze([
|
||||||
['alternative', 'Alternative'],
|
['alternative', msg('Alternative')],
|
||||||
['ambient', 'Ambient'],
|
['ambient', msg('Ambient')],
|
||||||
['blues', 'Blues'],
|
['blues', msg('Blues')],
|
||||||
['classical', 'Classical'],
|
['classical', msg('Classical')],
|
||||||
['country', 'Country'],
|
['country', msg('Country')],
|
||||||
['dance', 'Dance'],
|
['dance', msg('Dance')],
|
||||||
['electronic', 'Electronic'],
|
['electronic', msg('Electronic')],
|
||||||
['folk', 'Folk'],
|
['folk', msg('Folk')],
|
||||||
['funk', 'Funk'],
|
['funk', msg('Funk')],
|
||||||
['hip hop', 'Hip-Hop'],
|
['hip hop', msg('Hip-Hop')],
|
||||||
['house', 'House'],
|
['house', msg('House')],
|
||||||
['indie', 'Indie'],
|
['indie', msg('Indie')],
|
||||||
['jazz', 'Jazz'],
|
['jazz', msg('Jazz')],
|
||||||
['latin', 'Latin'],
|
['latin', msg('Latin')],
|
||||||
['metal', 'Metal'],
|
['metal', msg('Metal')],
|
||||||
['oldies', 'Oldies'],
|
['oldies', msg('Oldies')],
|
||||||
['pop', 'Pop'],
|
['pop', msg('Pop')],
|
||||||
['punk', 'Punk'],
|
['punk', msg('Punk')],
|
||||||
['r&b', 'R&B'],
|
['r&b', msg('R&B')],
|
||||||
['reggae', 'Reggae'],
|
['reggae', msg('Reggae')],
|
||||||
['rock', 'Rock'],
|
['rock', msg('Rock')],
|
||||||
['soul', 'Soul'],
|
['soul', msg('Soul')],
|
||||||
['techno', 'Techno'],
|
['techno', msg('Techno')],
|
||||||
['trance', 'Trance'],
|
['trance', msg('Trance')],
|
||||||
['world', 'World'],
|
['world', msg('World')],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const CATEGORY_MATCHERS = Object.freeze({
|
export const CATEGORY_MATCHERS = Object.freeze({
|
||||||
@@ -127,17 +131,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
|
|||||||
other: '#9aa7b3',
|
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([
|
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
|
||||||
'news',
|
'news',
|
||||||
'public-safety',
|
'public-safety',
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -28,11 +28,12 @@ import {
|
|||||||
parseCandidateKey,
|
parseCandidateKey,
|
||||||
quantizeBox,
|
quantizeBox,
|
||||||
rankLatest,
|
rankLatest,
|
||||||
shortDay,
|
|
||||||
utcDay,
|
utcDay,
|
||||||
validateBox,
|
validateBox,
|
||||||
viewTooLargeMessage,
|
viewTooLargeMessage,
|
||||||
} from './model.js';
|
} 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
|
* 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). */
|
/** The three modes, in share-link order (`m` = index). */
|
||||||
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
|
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
|
||||||
|
|
||||||
const COMPARISON_IN_USE = 'Comparison in use by another scene';
|
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
|
||||||
const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
|
// Kept English in the snapshot (AI tools read it); the card translates them.
|
||||||
const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
|
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
|
||||||
const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
|
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. */
|
/** ZOOM IN's flight time, in seconds. */
|
||||||
const ZOOM_FIT_SECONDS = 1.2;
|
const ZOOM_FIT_SECONDS = 1.2;
|
||||||
const DEFAULT_SPLIT = 0.5;
|
const DEFAULT_SPLIT = 0.5;
|
||||||
@@ -748,15 +750,21 @@ export function createRecentImageryLayer({
|
|||||||
|
|
||||||
function notes() {
|
function notes() {
|
||||||
const out = [];
|
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) {
|
for (const error of _catalogErrors) {
|
||||||
const label = PRODUCTS[error.product]?.label || error.product;
|
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 today = utcDay(now());
|
||||||
const viirsToday = candidateFor(`VIIRS:${today}`);
|
const viirsToday = candidateFor(`VIIRS:${today}`);
|
||||||
if (viirsToday?.availability === 'unknown' && _sources.viirs)
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +775,7 @@ export function createRecentImageryLayer({
|
|||||||
key: key || null,
|
key: key || null,
|
||||||
candidate: candidateFor(key),
|
candidate: candidateFor(key),
|
||||||
label: parsed
|
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,
|
: null,
|
||||||
sourceOff,
|
sourceOff,
|
||||||
drapable: drapable(candidateFor(key)),
|
drapable: drapable(candidateFor(key)),
|
||||||
@@ -1227,7 +1235,7 @@ export function createRecentImageryLayer({
|
|||||||
_alpha = 1;
|
_alpha = 1;
|
||||||
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
|
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
|
||||||
}
|
}
|
||||||
_notice = 'Box and images cleared';
|
_notice = msg('Box and images cleared');
|
||||||
syncRender();
|
syncRender();
|
||||||
publish([...BOX_EDGES, 'a', 'b', 'split']);
|
publish([...BOX_EDGES, 'a', 'b', 'split']);
|
||||||
notify();
|
notify();
|
||||||
@@ -1276,7 +1284,9 @@ export function createRecentImageryLayer({
|
|||||||
candidates,
|
candidates,
|
||||||
focusIndex,
|
focusIndex,
|
||||||
focus,
|
focus,
|
||||||
readout: focus ? formatCandidateReadout(focus, now()) : '',
|
readout: focus
|
||||||
|
? formatCandidateReadout(focus, now(), READOUT_WORDS)
|
||||||
|
: '',
|
||||||
recommended: ranked.candidate
|
recommended: ranked.candidate
|
||||||
? { key: ranked.candidate.key, reason: ranked.reason }
|
? { key: ranked.candidate.key, reason: ranked.reason }
|
||||||
: null,
|
: null,
|
||||||
@@ -1329,17 +1339,18 @@ export function createRecentImageryLayer({
|
|||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'hls',
|
id: 'hls',
|
||||||
label: 'MORE DETAIL · 30 m',
|
label: t('MORE DETAIL · 30 m'),
|
||||||
active: _sources.hls,
|
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 },
|
params: { hls: !_sources.hls },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'viirs',
|
id: 'viirs',
|
||||||
label: 'DAILY OVERVIEW · 250 m',
|
label: t('DAILY OVERVIEW · 250 m'),
|
||||||
active: _sources.viirs,
|
active: _sources.viirs,
|
||||||
title:
|
title: t(
|
||||||
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
|
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
|
||||||
|
),
|
||||||
params: { viirs: !_sources.viirs },
|
params: { viirs: !_sources.viirs },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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.focus.key, S18, 'focus starts on it');
|
||||||
assert.equal(snapshot.preview.key, S18);
|
assert.equal(snapshot.preview.key, S18);
|
||||||
assert.equal(snapshot.preview.slot, 'a');
|
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);
|
assert.equal(snapshot.candidates[1].preview, true);
|
||||||
const none = {
|
const none = {
|
||||||
key: null,
|
key: null,
|
||||||
|
|||||||
@@ -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,
|
* 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,
|
* 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({
|
export const PRODUCTS = Object.freeze({
|
||||||
S30: Object.freeze({
|
S30: Object.freeze({
|
||||||
label: 'HLS S30',
|
label: 'HLS S30', // i18n-ignore: product code
|
||||||
name: 'Sentinel-2',
|
name: msg('Sentinel-2'),
|
||||||
source: 'hls',
|
source: 'hls',
|
||||||
sensor: 'Sentinel-2 via HLS',
|
sensor: msg('Sentinel-2 via HLS'),
|
||||||
resolutionM: 30,
|
resolutionM: 30,
|
||||||
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
||||||
maxLevel: 12,
|
maxLevel: 12,
|
||||||
@@ -41,10 +45,10 @@ export const PRODUCTS = Object.freeze({
|
|||||||
overview: false,
|
overview: false,
|
||||||
}),
|
}),
|
||||||
L30: Object.freeze({
|
L30: Object.freeze({
|
||||||
label: 'HLS L30',
|
label: 'HLS L30', // i18n-ignore: product code
|
||||||
name: 'Landsat 8/9',
|
name: msg('Landsat 8/9'),
|
||||||
source: 'hls',
|
source: 'hls',
|
||||||
sensor: 'Landsat 8/9 via HLS',
|
sensor: msg('Landsat 8/9 via HLS'),
|
||||||
resolutionM: 30,
|
resolutionM: 30,
|
||||||
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
||||||
maxLevel: 12,
|
maxLevel: 12,
|
||||||
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
|
|||||||
overview: false,
|
overview: false,
|
||||||
}),
|
}),
|
||||||
VIIRS: Object.freeze({
|
VIIRS: Object.freeze({
|
||||||
label: 'VIIRS',
|
label: 'VIIRS', // i18n-ignore: product code
|
||||||
name: 'Daily overview',
|
name: msg('Daily overview'),
|
||||||
source: 'viirs',
|
source: 'viirs',
|
||||||
sensor: 'VIIRS NOAA-21',
|
sensor: 'VIIRS NOAA-21',
|
||||||
resolutionM: 250,
|
resolutionM: 250,
|
||||||
@@ -262,6 +266,11 @@ export function utcDay(value) {
|
|||||||
: null;
|
: 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. */
|
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
|
||||||
export function shortDay(day) {
|
export function shortDay(day) {
|
||||||
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(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 };
|
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);
|
const today = utcDay(now);
|
||||||
if (!today) return null;
|
if (!today) return null;
|
||||||
const delta = Math.round(
|
return Math.round(
|
||||||
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
||||||
DAY_MS,
|
DAY_MS,
|
||||||
);
|
);
|
||||||
if (delta <= 0) return 'today';
|
|
||||||
return delta === 1 ? 'yesterday' : `${delta} days ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloudLabel(cloud) {
|
function cloudLabel(cloud) {
|
||||||
@@ -497,18 +505,40 @@ function cloudLabel(cloud) {
|
|||||||
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene 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.
|
* One-line UTC readout, e.g.
|
||||||
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
||||||
* @param {object} candidate
|
* @param {object} candidate
|
||||||
* @param {string | number | Date} [now]
|
* @param {string | number | Date} [now]
|
||||||
|
* @param {Partial<typeof READOUT_WORDS>} [words] the page's localized words
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatCandidateReadout(candidate, now = new Date()) {
|
export function formatCandidateReadout(candidate, now = new Date(), words) {
|
||||||
const product = PRODUCTS[candidate?.product];
|
const product = PRODUCTS[candidate?.product];
|
||||||
if (!product || !candidate?.day) return '';
|
if (!product || !isValidDay(candidate?.day)) return '';
|
||||||
const [y, m, d] = candidate.day.split('-').map(Number);
|
const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
|
||||||
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
|
let when = say.date(candidate.day);
|
||||||
if (candidate.timeRange?.start) {
|
if (candidate.timeRange?.start) {
|
||||||
const start = hhmm(candidate.timeRange.start);
|
const start = hhmm(candidate.timeRange.start);
|
||||||
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
||||||
@@ -518,11 +548,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
|
|||||||
: ` ${start}Z`;
|
: ` ${start}Z`;
|
||||||
}
|
}
|
||||||
const parts = [when];
|
const parts = [when];
|
||||||
const ago = daysAgoLabel(candidate.day, now);
|
const delta = daysBetween(candidate.day, now);
|
||||||
if (ago) parts.push(ago);
|
if (delta !== null) parts.push(say.daysAgo(delta));
|
||||||
parts.push(product.sensor, `${product.resolutionM} m`);
|
parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
|
||||||
if (product.overview) parts.push('overview');
|
if (product.overview) parts.push(say.overview());
|
||||||
parts.push(cloudLabel(candidate.cloud));
|
parts.push(say.cloud(candidate.cloud));
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
|||||||
id: String(ISS_NORAD),
|
id: String(ISS_NORAD),
|
||||||
position,
|
position,
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
title: 'ISS',
|
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
|
||||||
accent: '#ff4444',
|
accent: '#ff4444',
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
collisionGroup: 'ambient-label',
|
collisionGroup: 'ambient-label',
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) {
|
|||||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||||
// must not leak onto a row that has no satellite identity.
|
// must not leak onto a row that has no satellite identity.
|
||||||
satelliteClass:
|
satelliteClass:
|
||||||
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
|
noradId || group
|
||||||
|
? satelliteClassLabel(group, { isIss, localize: false })
|
||||||
|
: null,
|
||||||
group,
|
group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
|||||||
name,
|
name,
|
||||||
operator: '',
|
operator: '',
|
||||||
noradId: String(noradId),
|
noradId: String(noradId),
|
||||||
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
|
class: satelliteClassLabel(sat.group, {
|
||||||
|
isIss: noradId === ISS_NORAD,
|
||||||
|
localize: false,
|
||||||
|
}),
|
||||||
altitude:
|
altitude:
|
||||||
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
|
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
|||||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||||
import { createMeshSampler } from './meshSampler.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. */
|
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||||
const SURFACE_WARM_BELOW_M = 6000;
|
const SURFACE_WARM_BELOW_M = 6000;
|
||||||
@@ -335,7 +336,9 @@ export function createStreetLevelLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unavailableReason(entry) {
|
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. */
|
/** 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. */
|
/** The hint a ground-click provider shows, or the provider's own. */
|
||||||
function hintFor(entry, stats) {
|
function hintFor(entry, stats) {
|
||||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
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 || '';
|
return stats.hint || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||||
import { createMapsLoader } from './mapsLoader.js';
|
import { createMapsLoader } from './mapsLoader.js';
|
||||||
|
import { getLocale } from '../../../../i18n/index.js';
|
||||||
import { createGoogleViewer } from './viewer.js';
|
import { createGoogleViewer } from './viewer.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -48,7 +49,7 @@ function settleWithin(promise, { ms, signal, loader }) {
|
|||||||
*/
|
*/
|
||||||
export function createGoogleProvider({
|
export function createGoogleProvider({
|
||||||
getApiKey,
|
getApiKey,
|
||||||
loader = createMapsLoader({ getApiKey }),
|
loader = createMapsLoader({ getApiKey, getLanguage: getLocale }),
|
||||||
}) {
|
}) {
|
||||||
if (typeof getApiKey !== 'function')
|
if (typeof getApiKey !== 'function')
|
||||||
throw new TypeError('Google Street View needs getApiKey');
|
throw new TypeError('Google Street View needs getApiKey');
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ const READY_CALLBACK = '__gevGoogleMapsReady';
|
|||||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
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({
|
export function createMapsLoader({
|
||||||
getApiKey,
|
getApiKey,
|
||||||
|
getLanguage,
|
||||||
document = globalThis.document,
|
document = globalThis.document,
|
||||||
global = globalThis,
|
global = globalThis,
|
||||||
}) {
|
}) {
|
||||||
@@ -53,12 +56,15 @@ export function createMapsLoader({
|
|||||||
};
|
};
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
key,
|
key,
|
||||||
v: 'weekly',
|
v: 'weekly',
|
||||||
loading: 'async',
|
loading: 'async',
|
||||||
callback: READY_CALLBACK,
|
callback: READY_CALLBACK,
|
||||||
})}`;
|
});
|
||||||
|
const language = getLanguage?.();
|
||||||
|
if (language && language !== 'en') params.set('language', language);
|
||||||
|
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${params}`;
|
||||||
script.onerror = () => {
|
script.onerror = () => {
|
||||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||||
script.remove();
|
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.deepEqual(heard, ['a']);
|
||||||
assert.equal(before, 1);
|
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 { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||||
|
import { msg } from '../../../../i18n/index.js';
|
||||||
|
|
||||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||||
|
|
||||||
export const GOOGLE_PROVIDER_ID = 'google';
|
export const GOOGLE_PROVIDER_ID = 'google';
|
||||||
export const GOOGLE_NAME = 'Google Street View';
|
export const GOOGLE_NAME = msg('Google Street View');
|
||||||
export const GOOGLE_LABEL = '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. */
|
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||||
export const GOOGLE_KEY_ID = 'google-maps';
|
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>';
|
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||||
|
|
||||||
/** Shown when Google refuses the key (gm_authFailure). */
|
/** Shown when Google refuses the key (gm_authFailure). */
|
||||||
export const KEY_REJECTED_MESSAGE =
|
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';
|
'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. */
|
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
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. */
|
/** Pause after a failed details lookup before a view event can retry. */
|
||||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||||
|
|
||||||
export const NO_ANSWER_MESSAGE =
|
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';
|
'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). */
|
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||||
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
PANO_FOV_DEFAULT_DEG,
|
PANO_FOV_DEFAULT_DEG,
|
||||||
wrapDegrees,
|
wrapDegrees,
|
||||||
} from './panorama.js';
|
} from './panorama.js';
|
||||||
|
import { t } from '../../../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
FLAT_FULL_SIZE,
|
FLAT_FULL_SIZE,
|
||||||
FLAT_VIEW_SIZE,
|
FLAT_VIEW_SIZE,
|
||||||
@@ -436,11 +437,11 @@ export function createKartaViewViewer({
|
|||||||
root.setAttribute('role', 'group');
|
root.setAttribute('role', 'group');
|
||||||
root.setAttribute(
|
root.setAttribute(
|
||||||
'aria-label',
|
'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 = doc.createElement('img');
|
||||||
picture.className = 'sl-kv-photo';
|
picture.className = 'sl-kv-photo';
|
||||||
picture.alt = 'KartaView street-level photo';
|
picture.alt = t('KartaView street-level photo');
|
||||||
picture.draggable = false;
|
picture.draggable = false;
|
||||||
picture.hidden = true;
|
picture.hidden = true;
|
||||||
canvas = makeCanvas(doc);
|
canvas = makeCanvas(doc);
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ export function composeUIState({
|
|||||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
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 {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
groundClickReady: groundClickReady === true,
|
groundClickReady: groundClickReady === true,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function createBundledCableSource({
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: 'TeleGeography',
|
label: 'TeleGeography', // i18n-ignore: data provider name
|
||||||
async fetch(signal) {
|
async fetch(signal) {
|
||||||
const [cables, landingPoints] = await Promise.all([
|
const [cables, landingPoints] = await Promise.all([
|
||||||
read(cableUrl(), signal),
|
read(cableUrl(), signal),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
accentForVesselType,
|
accentForVesselType,
|
||||||
normalizeVesselType,
|
displayVesselType,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export function createCards({
|
export function createCards({
|
||||||
vesselState,
|
vesselState,
|
||||||
@@ -130,7 +130,7 @@ export function createCards({
|
|||||||
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
||||||
|
|
||||||
function vesselTypeShort(record) {
|
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) {
|
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) {
|
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) {
|
function formatPositionTime(record) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { msg } from '../../i18n/index.js';
|
||||||
|
|
||||||
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
||||||
export function resolveImageryHost({ viewer, tileset }) {
|
export function resolveImageryHost({ viewer, tileset }) {
|
||||||
if (viewer?.scene?.globe?.show === true)
|
if (viewer?.scene?.globe?.show === true)
|
||||||
@@ -7,7 +9,9 @@ export function resolveImageryHost({ viewer, tileset }) {
|
|||||||
return { collection: null, kind: 'none' };
|
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. */
|
/** Report imagery suspension without changing or detaching the resolved host. */
|
||||||
export function imageryHostStatus(host) {
|
export function imageryHostStatus(host) {
|
||||||
|
|||||||
+132
-53
@@ -7,6 +7,8 @@ import {
|
|||||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||||
LIGHTNING_MAX_GAP_MS,
|
LIGHTNING_MAX_GAP_MS,
|
||||||
} from './clock.js';
|
} from './clock.js';
|
||||||
|
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
|
||||||
|
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||||
|
|
||||||
const STOPS = [
|
const STOPS = [
|
||||||
[10, '#00ecec'],
|
[10, '#00ecec'],
|
||||||
@@ -29,7 +31,9 @@ const LIGHTNING_STOPS = [
|
|||||||
['300+', '#00FF00'],
|
['300+', '#00FF00'],
|
||||||
];
|
];
|
||||||
const utc = (value) =>
|
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
|
/** A per-application observation layer, using the existing layer lifecycle and
|
||||||
* row controls. History is transient: shared links always open latest imagery. */
|
* row controls. History is transient: shared links always open latest imagery. */
|
||||||
@@ -227,12 +231,14 @@ export function createWeatherLayer({
|
|||||||
rendering.setHidden?.(false);
|
rendering.setHidden?.(false);
|
||||||
void warmNext(time);
|
void warmNext(time);
|
||||||
}
|
}
|
||||||
error = ok ? null : 'Frame unavailable; previous observation retained';
|
error = ok
|
||||||
|
? null
|
||||||
|
: msg('Frame unavailable; previous observation retained');
|
||||||
if (!ok) stop();
|
if (!ok) stop();
|
||||||
return ok;
|
return ok;
|
||||||
} catch {
|
} catch {
|
||||||
if (owner === generation && !controller.signal.aborted) {
|
if (owner === generation && !controller.signal.aborted) {
|
||||||
error = 'Weather imagery unavailable';
|
error = msg('Weather imagery unavailable');
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -338,7 +344,9 @@ export function createWeatherLayer({
|
|||||||
if (!enabled || controller.signal.aborted || request !== controller)
|
if (!enabled || controller.signal.aborted || request !== controller)
|
||||||
return false;
|
return false;
|
||||||
if (snapshot.unavailable) {
|
if (snapshot.unavailable) {
|
||||||
error = 'Weather source unavailable; previous observation retained';
|
error = msg(
|
||||||
|
'Weather source unavailable; previous observation retained',
|
||||||
|
);
|
||||||
stop();
|
stop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -368,7 +376,7 @@ export function createWeatherLayer({
|
|||||||
return !controller.signal.aborted && request === controller && enabled;
|
return !controller.signal.aborted && request === controller && enabled;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || request !== controller) return false;
|
if (controller.signal.aborted || request !== controller) return false;
|
||||||
error = cause?.message || 'Weather unavailable';
|
error = cause?.message || msg('Weather unavailable');
|
||||||
stop();
|
stop();
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -487,15 +495,23 @@ export function createWeatherLayer({
|
|||||||
noFrame &&
|
noFrame &&
|
||||||
shared?.mode === 'history' &&
|
shared?.mode === 'history' &&
|
||||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
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;
|
: null;
|
||||||
const time = shownTime();
|
const time = shownTime();
|
||||||
const relation =
|
const relation =
|
||||||
shared?.mode === 'history' && time
|
shared?.mode === 'history' && time
|
||||||
? Date.parse(time) === Date.parse(shared.target)
|
? Date.parse(time) === Date.parse(shared.target)
|
||||||
? ' · synced'
|
? 'synced'
|
||||||
: Date.parse(time) < Date.parse(shared.target)
|
: Date.parse(time) < Date.parse(shared.target)
|
||||||
? ' · nearest'
|
? 'nearest'
|
||||||
: ''
|
: ''
|
||||||
: '';
|
: '';
|
||||||
const times = manifest?.times || [];
|
const times = manifest?.times || [];
|
||||||
@@ -508,9 +524,16 @@ export function createWeatherLayer({
|
|||||||
age === null
|
age === null
|
||||||
? ''
|
? ''
|
||||||
: age >= 60
|
: age >= 60
|
||||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
? t('{hours}h {minutes}m ago', {
|
||||||
: `${age}m ago`;
|
hours: Math.floor(age / 60),
|
||||||
|
minutes: age % 60,
|
||||||
|
})
|
||||||
|
: t('{minutes}m ago', { minutes: age });
|
||||||
const diagnostic = rendering?.getDiagnostics();
|
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 b = manifest?.bounds;
|
||||||
const canvas = viewer?.scene?.canvas;
|
const canvas = viewer?.scene?.canvas;
|
||||||
const point =
|
const point =
|
||||||
@@ -535,49 +558,94 @@ export function createWeatherLayer({
|
|||||||
lat < b.south ||
|
lat < b.south ||
|
||||||
lat > b.north ||
|
lat > b.north ||
|
||||||
(lightning && lon > 0 && lon < 110));
|
(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 = {
|
const controls = {
|
||||||
readout: true,
|
readout: true,
|
||||||
summary: {
|
summary: {
|
||||||
label: radar
|
label: radar
|
||||||
? 'Rain radar · US'
|
? t('Rain radar · US')
|
||||||
: lightning
|
: lightning
|
||||||
? 'Lightning density · 15 min'
|
? t('Lightning density · 15 min')
|
||||||
: 'Satellite clouds',
|
: t('Satellite clouds'),
|
||||||
coverage: radar
|
coverage: radar
|
||||||
? 'CONUS'
|
? 'CONUS'
|
||||||
: lightning
|
: lightning
|
||||||
? 'Americas + Pacific'
|
? t('Americas + Pacific')
|
||||||
: product === 'clouds'
|
: product === 'clouds'
|
||||||
? 'Global · 60°S–60°N'
|
? t('Global · 60°S–60°N')
|
||||||
: 'North America',
|
: t('North America'),
|
||||||
shownTime: time,
|
shownTime: time,
|
||||||
maxGapMinutes: maxGap() / 60_000,
|
maxGapMinutes: maxGap() / 60_000,
|
||||||
detail: time
|
detail,
|
||||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
|
||||||
: missing
|
|
||||||
? 'Observation unavailable'
|
|
||||||
: 'Waiting for observation',
|
|
||||||
status:
|
status:
|
||||||
missing ||
|
missing ||
|
||||||
hostStatus ||
|
shownHost ||
|
||||||
error ||
|
shownProblem ||
|
||||||
diagnostic?.error ||
|
|
||||||
(observationDelayed()
|
(observationDelayed()
|
||||||
? 'Source observations delayed'
|
? t('Source observations delayed')
|
||||||
: manifest?.stale
|
: manifest?.stale
|
||||||
? 'Stale source'
|
? t('Stale source')
|
||||||
: loading
|
: loading
|
||||||
? 'Loading next frame…'
|
? t('Loading next frame…')
|
||||||
: outside
|
: outside
|
||||||
? 'Map center outside coverage'
|
? t('Map center outside coverage')
|
||||||
: null),
|
: null),
|
||||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||||
},
|
},
|
||||||
chips: [
|
chips: [
|
||||||
...(satellite
|
...(satellite
|
||||||
? [
|
? [
|
||||||
['clouds-regional', 'N. America'],
|
['clouds-regional', t('N. America')],
|
||||||
['clouds', 'Global'],
|
['clouds', t('Global')],
|
||||||
].map(([value, label]) => ({
|
].map(([value, label]) => ({
|
||||||
id: value,
|
id: value,
|
||||||
label,
|
label,
|
||||||
@@ -585,43 +653,44 @@ export function createWeatherLayer({
|
|||||||
params: { product: value },
|
params: { product: value },
|
||||||
title:
|
title:
|
||||||
value === 'clouds'
|
value === 'clouds'
|
||||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
? t('Hourly global mosaic; usually 2–3 hours delayed')
|
||||||
: 'GOES regional clouds; approximately 5-minute updates',
|
: t('GOES regional clouds; approximately 5-minute updates'),
|
||||||
}))
|
}))
|
||||||
: []),
|
: []),
|
||||||
...(satellite
|
...(satellite
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'filtered',
|
id: 'filtered',
|
||||||
label: 'Clouds only',
|
label: t('Clouds only'),
|
||||||
active: infrared === 'filtered',
|
active: infrared === 'filtered',
|
||||||
params: { infrared: 'filtered' },
|
params: { infrared: 'filtered' },
|
||||||
title:
|
title: t(
|
||||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'full',
|
id: 'full',
|
||||||
label: 'Full',
|
label: t('Full'),
|
||||||
active: infrared === 'full',
|
active: infrared === 'full',
|
||||||
params: { 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) => ({
|
...['light', 'strong'].map((value) => ({
|
||||||
id: `opacity-${value}`,
|
id: `opacity-${value}`,
|
||||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
label: value === 'light' ? t('Soft') : t('Vivid'),
|
||||||
active: opacity === value,
|
active: opacity === value,
|
||||||
params: { 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',
|
id: 'coverage',
|
||||||
label: radar
|
label: radar
|
||||||
? 'View US radar'
|
? t('View US radar')
|
||||||
: lightning
|
: lightning
|
||||||
? 'View Americas & Pacific'
|
? t('View Americas & Pacific')
|
||||||
: 'View coverage',
|
: t('View coverage'),
|
||||||
disabled: !manifest || !runNavigation,
|
disabled: !manifest || !runNavigation,
|
||||||
params: { focus: true },
|
params: { focus: true },
|
||||||
},
|
},
|
||||||
@@ -632,37 +701,47 @@ export function createWeatherLayer({
|
|||||||
label: String(label),
|
label: String(label),
|
||||||
color,
|
color,
|
||||||
blurb: lightning
|
blurb: lightning
|
||||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||||
: `${label} dBZ radar reflectivity`,
|
value: formatScaleLabel(label),
|
||||||
|
})
|
||||||
|
: t('{value} dBZ radar reflectivity', {
|
||||||
|
value: formatScaleLabel(label),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
info: hostHidden
|
info: hostHidden
|
||||||
? hostStatus
|
? shownHost
|
||||||
: `${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') : ''}`,
|
: `${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
|
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
|
: 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.'
|
? 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.',
|
'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 = [
|
controls.summary.settings = [
|
||||||
...(satellite
|
...(satellite
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'region',
|
id: 'region',
|
||||||
label: 'REGION',
|
label: t('REGION'),
|
||||||
chips: controls.chips.filter(({ params }) => params.product),
|
chips: controls.chips.filter(({ params }) => params.product),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'image',
|
id: 'image',
|
||||||
label: 'IMAGE',
|
label: t('IMAGE'),
|
||||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
id: 'opacity',
|
id: 'opacity',
|
||||||
label: 'OPACITY',
|
label: t('OPACITY'),
|
||||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -677,7 +756,7 @@ export function createWeatherLayer({
|
|||||||
getStats() {
|
getStats() {
|
||||||
return {
|
return {
|
||||||
count: shownTime() ? 1 : 0,
|
count: shownTime() ? 1 : 0,
|
||||||
countLabel: isLatest() ? 'Observed' : 'History',
|
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||||
loading,
|
loading,
|
||||||
error: error || rendering?.getDiagnostics().error || null,
|
error: error || rendering?.getDiagnostics().error || null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
|
|||||||
import { imageryHostStatus } from './imageryHost.js';
|
import { imageryHostStatus } from './imageryHost.js';
|
||||||
import { createRasterTileProvider } from './rasterTiles.js';
|
import { createRasterTileProvider } from './rasterTiles.js';
|
||||||
import { createWeatherShell } from './shellRendering.js';
|
import { createWeatherShell } from './shellRendering.js';
|
||||||
|
import { msg } from '../../i18n/index.js';
|
||||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||||
import {
|
import {
|
||||||
acquireInfraredMosaic,
|
acquireInfraredMosaic,
|
||||||
@@ -294,7 +295,9 @@ function createGlobeRendering({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
lastError = msg(
|
||||||
|
'Weather tiles unavailable · previous frame retained',
|
||||||
|
);
|
||||||
remove(frame);
|
remove(frame);
|
||||||
}
|
}
|
||||||
frame.resolve(ok);
|
frame.resolve(ok);
|
||||||
@@ -413,7 +416,7 @@ function createGlobeRendering({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
frame.failed = true;
|
frame.failed = true;
|
||||||
lastError = 'Some weather tiles unavailable';
|
lastError = msg('Some weather tiles unavailable');
|
||||||
onChange();
|
onChange();
|
||||||
});
|
});
|
||||||
// A shown, transparent layer lets Cesium request staging tiles while the last
|
// A shown, transparent layer lets Cesium request staging tiles while the last
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { acquireWeatherImage } from './infraredImage.js';
|
import { acquireWeatherImage } from './infraredImage.js';
|
||||||
import { imageryHostStatus } from './imageryHost.js';
|
import { imageryHostStatus } from './imageryHost.js';
|
||||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.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. */
|
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||||||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||||||
@@ -630,7 +631,7 @@ export function createWeatherShell({
|
|||||||
if (incoming !== frame) return;
|
if (incoming !== frame) return;
|
||||||
incoming = null;
|
incoming = null;
|
||||||
close(frame);
|
close(frame);
|
||||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
lastError = msg('Weather tiles unavailable · previous frame retained');
|
||||||
frame.resolve(false);
|
frame.resolve(false);
|
||||||
scene.requestRender();
|
scene.requestRender();
|
||||||
onChange();
|
onChange();
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
|
|||||||
/** Fixed physical scales shared by imagery and the field legend. */
|
/** Fixed physical scales shared by imagery and the field legend. */
|
||||||
export const WIND_FIELDS = Object.freeze({
|
export const WIND_FIELDS = Object.freeze({
|
||||||
speed: 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',
|
units: 'm/s',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 30,
|
max: 30,
|
||||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||||
}),
|
}),
|
||||||
temperature: Object.freeze({
|
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',
|
units: '°C',
|
||||||
min: -40,
|
min: -40,
|
||||||
max: 50,
|
max: 50,
|
||||||
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
pressure: 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',
|
units: 'hPa',
|
||||||
min: 960,
|
min: 960,
|
||||||
max: 1050,
|
max: 1050,
|
||||||
|
|||||||
+56
-41
@@ -11,6 +11,8 @@ import {
|
|||||||
createWindInspectionMarker,
|
createWindInspectionMarker,
|
||||||
WIND_UNITS,
|
WIND_UNITS,
|
||||||
} from './inspection.js';
|
} from './inspection.js';
|
||||||
|
import { t, msg } from '../../i18n/index.js';
|
||||||
|
import { unitLabel } from '../../ui/unitLabels.js';
|
||||||
|
|
||||||
/** Format a forecast timestamp explicitly in UTC. */
|
/** Format a forecast timestamp explicitly in UTC. */
|
||||||
export function formatWindValidTime(value) {
|
export function formatWindValidTime(value) {
|
||||||
@@ -172,7 +174,7 @@ export function createWindLayer({
|
|||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || request !== controller) return false;
|
if (controller.signal.aborted || request !== controller) return false;
|
||||||
error = cause?.message || 'Wind source unavailable';
|
error = cause?.message || msg('Wind source unavailable');
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
@@ -242,7 +244,7 @@ export function createWindLayer({
|
|||||||
getRowControls() {
|
getRowControls() {
|
||||||
const historyStatus =
|
const historyStatus =
|
||||||
clock?.getState().mode === 'history'
|
clock?.getState().mode === 'history'
|
||||||
? 'Forecast · does not follow history'
|
? t('Forecast · does not follow history')
|
||||||
: null;
|
: null;
|
||||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||||
@@ -258,13 +260,16 @@ export function createWindLayer({
|
|||||||
requestedScalar() !== 'none' &&
|
requestedScalar() !== 'none' &&
|
||||||
manifest &&
|
manifest &&
|
||||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||||
|
// Stored in English for diagnostics; shown translated.
|
||||||
const imageryError =
|
const imageryError =
|
||||||
enabled && !loading ? diagnostic?.imageryError : null;
|
enabled && !loading && diagnostic?.imageryError
|
||||||
|
? t(diagnostic.imageryError)
|
||||||
|
: null;
|
||||||
const label = {
|
const label = {
|
||||||
none: 'Wind motion',
|
none: t('Wind motion'),
|
||||||
speed: 'Wind speed',
|
speed: t('Wind speed'),
|
||||||
pressure: 'Sea-level pressure',
|
pressure: t('Sea-level pressure'),
|
||||||
temperature: 'Air temperature · 2 m',
|
temperature: t('Air temperature · 2 m'),
|
||||||
}[overlay];
|
}[overlay];
|
||||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||||
? overlay
|
? overlay
|
||||||
@@ -290,60 +295,64 @@ export function createWindLayer({
|
|||||||
readout: true,
|
readout: true,
|
||||||
summary: {
|
summary: {
|
||||||
label,
|
label,
|
||||||
coverage: 'Global · 1° grid',
|
coverage: t('Global · 1° grid'),
|
||||||
validTime: manifest?.cycle?.validIso,
|
validTime: manifest?.cycle?.validIso,
|
||||||
issuedTime: manifest?.cycle?.runIso,
|
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
|
status: loading
|
||||||
? 'Loading forecast'
|
? t('Loading forecast')
|
||||||
: preparing
|
: preparing
|
||||||
? 'Preparing flow'
|
? t('Preparing flow')
|
||||||
: error ||
|
: (error && t(error)) ||
|
||||||
manifest?.reason ||
|
manifest?.reason ||
|
||||||
imageryError ||
|
imageryError ||
|
||||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
(scalarMissing ? t('Selected field unavailable') : null) ||
|
||||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
(manifest?.stale ? t('Cached forecast · stale') : null),
|
||||||
units: legendUnit,
|
units: legendUnit,
|
||||||
},
|
},
|
||||||
chips: [
|
chips: [
|
||||||
...['gfs', 'ifs'].map((value) => ({
|
...['gfs', 'ifs'].map((value) => ({
|
||||||
id: `model-${value}`,
|
id: `model-${value}`,
|
||||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
|
||||||
active: model === value,
|
active: model === value,
|
||||||
params: { model: value },
|
params: { model: value },
|
||||||
title:
|
title:
|
||||||
value === 'ifs'
|
value === 'ifs'
|
||||||
? 'ECMWF IFS surface forecast'
|
? t('ECMWF IFS surface forecast')
|
||||||
: 'NOAA GFS surface forecast',
|
: t('NOAA GFS surface forecast'),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
id: 'motion',
|
id: 'motion',
|
||||||
label: diagnostic?.reducedMotion
|
label: diagnostic?.reducedMotion
|
||||||
? 'Reduced motion'
|
? t('Reduced motion')
|
||||||
: paused
|
: paused
|
||||||
? 'Resume'
|
? t('Resume')
|
||||||
: 'Pause',
|
: t('Pause'),
|
||||||
active: motionPaused,
|
active: motionPaused,
|
||||||
disabled: Boolean(diagnostic?.reducedMotion),
|
disabled: Boolean(diagnostic?.reducedMotion),
|
||||||
params: { paused: !paused },
|
params: { paused: !paused },
|
||||||
title:
|
title: t(
|
||||||
'Pause visual flow; forecast time does not advance with animation',
|
'Pause visual flow; forecast time does not advance with animation',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||||
id: `overlay-${value}`,
|
id: `overlay-${value}`,
|
||||||
label: {
|
label: {
|
||||||
none: 'None',
|
none: t('None'),
|
||||||
speed: 'Speed',
|
speed: t('Speed'),
|
||||||
pressure: 'Pressure',
|
pressure: t('Pressure'),
|
||||||
temperature: 'Temperature',
|
temperature: t('Temperature'),
|
||||||
}[value],
|
}[value],
|
||||||
active: overlay === value,
|
active: overlay === value,
|
||||||
params: { overlay: value },
|
params: { overlay: value },
|
||||||
title: {
|
title: {
|
||||||
none: 'Wind trails with no field shading',
|
none: t('Wind trails with no field shading'),
|
||||||
speed: 'How hard the surface wind is blowing',
|
speed: t('How hard the surface wind is blowing'),
|
||||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
pressure: t('Sea-level air pressure: broad highs and lows'),
|
||||||
temperature: 'Air temperature two meters above the surface',
|
temperature: t('Air temperature two meters above the surface'),
|
||||||
}[value],
|
}[value],
|
||||||
})),
|
})),
|
||||||
...(speedLegendVisible ||
|
...(speedLegendVisible ||
|
||||||
@@ -353,18 +362,19 @@ export function createWindLayer({
|
|||||||
: []
|
: []
|
||||||
).map((value) => ({
|
).map((value) => ({
|
||||||
id: `units-${value}`,
|
id: `units-${value}`,
|
||||||
label: value,
|
label: unitLabel(value),
|
||||||
active: units === value,
|
active: units === value,
|
||||||
params: { units: value },
|
params: { units: value },
|
||||||
title: 'Wind speed units',
|
title: t('Wind speed units'),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
id: 'read-wind',
|
id: 'read-wind',
|
||||||
label: 'Read wind at map center',
|
label: t('Read wind at map center'),
|
||||||
params: { inspect: true },
|
params: { inspect: true },
|
||||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||||
title:
|
title: t(
|
||||||
'Read the forecast at the center of the map without changing selection',
|
'Read the forecast at the center of the map without changing selection',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
legend:
|
legend:
|
||||||
@@ -373,9 +383,14 @@ export function createWindLayer({
|
|||||||
(overlay === 'none' && !speedLegendVisible)
|
(overlay === 'none' && !speedLegendVisible)
|
||||||
? []
|
? []
|
||||||
: legend,
|
: 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' : ''}`,
|
info: `${t('{model} forecast · {field} ({unit})', {
|
||||||
infoTitle:
|
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.',
|
'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.reading = reading;
|
||||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||||
@@ -385,12 +400,12 @@ export function createWindLayer({
|
|||||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||||
});
|
});
|
||||||
controls.summary.settings = [
|
controls.summary.settings = [
|
||||||
setting('model', 'MODEL', 'model-'),
|
setting('model', t('MODEL'), 'model-'),
|
||||||
setting('field', 'FIELD', 'overlay-'),
|
setting('field', t('FIELD'), 'overlay-'),
|
||||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
...(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(
|
controls.summary.actions = controls.chips.filter(
|
||||||
({ id }) => id === 'read-wind',
|
({ id }) => id === 'read-wind',
|
||||||
@@ -425,7 +440,7 @@ export function createWindLayer({
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
...windStats(manifest),
|
...windStats(manifest),
|
||||||
countLabel: 'Forecast',
|
countLabel: t('Forecast'),
|
||||||
loading: loading || Boolean(preparing),
|
loading: loading || Boolean(preparing),
|
||||||
model: model.toUpperCase(),
|
model: model.toUpperCase(),
|
||||||
overlay,
|
overlay,
|
||||||
@@ -433,7 +448,7 @@ export function createWindLayer({
|
|||||||
stale: Boolean(manifest?.stale),
|
stale: Boolean(manifest?.stale),
|
||||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||||
validTime:
|
validTime:
|
||||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
|
||||||
error:
|
error:
|
||||||
error ||
|
error ||
|
||||||
windStats(manifest).error ||
|
windStats(manifest).error ||
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { sampleWind, windSpeed } from './model.js';
|
import { sampleWind, windSpeed } from './model.js';
|
||||||
import { sampleScalar } from './fields.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 = [
|
const DIRECTIONS = [
|
||||||
'N',
|
msg('N'),
|
||||||
'NNE',
|
msg('NNE'),
|
||||||
'NE',
|
msg('NE'),
|
||||||
'ENE',
|
msg('ENE'),
|
||||||
'E',
|
msg('E'),
|
||||||
'ESE',
|
msg('ESE'),
|
||||||
'SE',
|
msg('SE'),
|
||||||
'SSE',
|
msg('SSE'),
|
||||||
'S',
|
msg('S'),
|
||||||
'SSW',
|
msg('SSW'),
|
||||||
'SW',
|
msg('SW'),
|
||||||
'WSW',
|
msg('WSW'),
|
||||||
'W',
|
msg('W'),
|
||||||
'WNW',
|
msg('WNW'),
|
||||||
'NW',
|
msg('NW'),
|
||||||
'NNW',
|
msg('NNW'),
|
||||||
];
|
];
|
||||||
export const WIND_UNITS = Object.freeze({
|
export const WIND_UNITS = Object.freeze({
|
||||||
'km/h': 3.6,
|
'km/h': 3.6,
|
||||||
@@ -56,13 +60,14 @@ export function inspectWindAtCenter(
|
|||||||
));
|
));
|
||||||
if (!point || !snapshot?.u || !snapshot?.v)
|
if (!point || !snapshot?.u || !snapshot?.v)
|
||||||
return {
|
return {
|
||||||
coordinates: 'No surface reading',
|
coordinates: msg('No surface reading'),
|
||||||
wind: 'Aim the map center at Earth',
|
wind: msg('Aim the map center at Earth'),
|
||||||
model,
|
model,
|
||||||
validTime,
|
validTime,
|
||||||
status,
|
status,
|
||||||
explanation:
|
explanation: msg(
|
||||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||||
const lon = cesium.Math.toDegrees(location.longitude);
|
const lon = cesium.Math.toDegrees(location.longitude);
|
||||||
@@ -87,11 +92,11 @@ export function inspectWindAtCenter(
|
|||||||
scalarKind: overlay,
|
scalarKind: overlay,
|
||||||
scalarLabel:
|
scalarLabel:
|
||||||
overlay === 'speed'
|
overlay === 'speed'
|
||||||
? 'Wind speed'
|
? msg('Wind speed')
|
||||||
: overlay === 'temperature'
|
: overlay === 'temperature'
|
||||||
? 'Air temperature · 2 m'
|
? msg('Air temperature · 2 m')
|
||||||
: overlay === 'pressure'
|
: overlay === 'pressure'
|
||||||
? 'Sea-level pressure'
|
? msg('Sea-level pressure')
|
||||||
: null,
|
: null,
|
||||||
scalarValue:
|
scalarValue:
|
||||||
overlay === 'speed'
|
overlay === 'speed'
|
||||||
@@ -102,8 +107,9 @@ export function inspectWindAtCenter(
|
|||||||
model,
|
model,
|
||||||
validTime,
|
validTime,
|
||||||
status,
|
status,
|
||||||
explanation:
|
explanation: msg(
|
||||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
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. */
|
/** Reformat a captured sample; changing units never samples the map again. */
|
||||||
export function formatWindReading(reading, units) {
|
export function formatWindReading(reading, units) {
|
||||||
@@ -19,36 +21,111 @@ export function formatWindReading(reading, units) {
|
|||||||
export function windUnitChips(units) {
|
export function windUnitChips(units) {
|
||||||
return Object.keys(WIND_UNITS).map((value) => ({
|
return Object.keys(WIND_UNITS).map((value) => ({
|
||||||
id: `units-${value}`,
|
id: `units-${value}`,
|
||||||
label: value,
|
label: unitLabel(value),
|
||||||
active: units === value,
|
active: units === value,
|
||||||
params: { 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. */
|
/** Portable result block consumed by the WEATHER card. */
|
||||||
export function windReadingResult(reading) {
|
export function windReadingResult(reading) {
|
||||||
|
const coordinates =
|
||||||
|
reading.coordinates === 'No surface reading'
|
||||||
|
? t('No surface reading')
|
||||||
|
: displayCoordinates(reading.coordinates);
|
||||||
return {
|
return {
|
||||||
id: 'reading',
|
id: 'reading',
|
||||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
label: t('WIND AT {coordinates}', { coordinates }),
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
id: 'wind',
|
id: 'wind',
|
||||||
text: reading.wind,
|
text: windText(reading),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'meta',
|
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
|
...(reading.scalarValue
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'scalar',
|
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 } },
|
clear: { params: { inspect: false } },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
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', () => {
|
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||||
const reading = formatWindReading(sample, 'mph');
|
const reading = formatWindReading(sample, 'mph');
|
||||||
assert.equal(reading.wind, '11.2 mph from SW');
|
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);
|
assert.equal(sample.units, undefined);
|
||||||
const section = windReadingResult(reading);
|
const section = windReadingResult(reading);
|
||||||
assert.equal(section.id, 'reading');
|
assert.equal(section.id, 'reading');
|
||||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
assert.equal(
|
||||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
section.lines.find(({ id }) => id === 'scalar').text,
|
||||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
'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.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||||
assert.deepEqual(section.clear.params, { inspect: false });
|
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(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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { createWindRelief } from './relief.js';
|
import { createWindRelief } from './relief.js';
|
||||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||||
|
import { msg } from '../../i18n/index.js';
|
||||||
import { createWindGpuRendering } from './gpuRendering.js';
|
import { createWindGpuRendering } from './gpuRendering.js';
|
||||||
import { advectParticle, sampleWind } from './model.js';
|
import { advectParticle, sampleWind } from './model.js';
|
||||||
import {
|
import {
|
||||||
@@ -344,7 +345,7 @@ export function createWindRendering({
|
|||||||
? !cesium.Primitive
|
? !cesium.Primitive
|
||||||
: !collection || !cesium.SingleTileImageryProvider
|
: !collection || !cesium.SingleTileImageryProvider
|
||||||
) {
|
) {
|
||||||
imageryError = 'Globe imagery unavailable';
|
imageryError = msg('Globe imagery unavailable');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -384,12 +385,12 @@ export function createWindRendering({
|
|||||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||||
imageryError = 'Globe field image unavailable';
|
imageryError = msg('Globe field image unavailable');
|
||||||
});
|
});
|
||||||
viewer.scene?.requestRender?.();
|
viewer.scene?.requestRender?.();
|
||||||
} catch {
|
} catch {
|
||||||
removeImagery();
|
removeImagery();
|
||||||
imageryError = 'Globe field image unavailable';
|
imageryError = msg('Globe field image unavailable');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export function reduceTrafficSyncFeedback(
|
|||||||
// Neutral default: the layer always supplies its own LIVE/SIMULATED
|
// Neutral default: the layer always supplies its own LIVE/SIMULATED
|
||||||
// label, and a fallback string must never claim a live feed on a
|
// label, and a fallback string must never claim a live feed on a
|
||||||
// keyless build.
|
// keyless build.
|
||||||
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||||
label: label || 'syncing road network',
|
label: label || 'syncing road network',
|
||||||
progressText: hasProgress ? `${progressPct}%` : '...',
|
progressText: hasProgress ? `${progressPct}%` : '...',
|
||||||
};
|
};
|
||||||
@@ -392,6 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
state: 'retry',
|
state: 'retry',
|
||||||
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||||
label: (
|
label: (
|
||||||
camera.cameraRetry.error || 'Overpass temporarily unavailable'
|
camera.cameraRetry.error || 'Overpass temporarily unavailable'
|
||||||
).toUpperCase(),
|
).toUpperCase(),
|
||||||
@@ -438,6 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
|||||||
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
|
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
|
||||||
return {
|
return {
|
||||||
state: 'loading',
|
state: 'loading',
|
||||||
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||||
label: active[0].cameraRetry.retrying
|
label: active[0].cameraRetry.retrying
|
||||||
? 'RETRYING ALPR CAMERAS'
|
? 'RETRYING ALPR CAMERAS'
|
||||||
: 'FETCHING ALPR CAMERAS',
|
: 'FETCHING ALPR CAMERAS',
|
||||||
@@ -452,6 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
state: 'loading',
|
state: 'loading',
|
||||||
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||||
label: active[0].installationRetry.retrying
|
label: active[0].installationRetry.retrying
|
||||||
? 'RETRYING MAPPED SITES'
|
? 'RETRYING MAPPED SITES'
|
||||||
: 'FETCHING MAPPED SITES',
|
: 'FETCHING MAPPED SITES',
|
||||||
|
|||||||
@@ -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.equal(writes.length, 1, 'the style indicator must have exactly one writer');
|
||||||
assert.match(
|
assert.match(
|
||||||
ui.slice(writes[0].index, writes[0].index + 160),
|
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();
|
const handler = locationSearchHandler();
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
import { createStandaloneApplication } from './standalone/application.js';
|
import { createStandaloneApplication } from './standalone/application.js';
|
||||||
import { describeError } from './standalone/errors.js';
|
import { describeError } from './standalone/errors.js';
|
||||||
|
import { t } from './i18n/index.js';
|
||||||
|
|
||||||
const application = createStandaloneApplication({
|
const application = createStandaloneApplication({
|
||||||
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
||||||
@@ -10,7 +11,9 @@ const application = createStandaloneApplication({
|
|||||||
application.start().catch((error) => {
|
application.start().catch((error) => {
|
||||||
console.error("God's Eye View initialization failed:", error);
|
console.error("God's Eye View initialization failed:", error);
|
||||||
const loaderStatus = document.querySelector('#loading-screen .loader-status');
|
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';
|
loaderStatus.style.color = '#ff4444';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
html,
|
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(
|
assert.doesNotMatch(
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import { t } from '../i18n/index.js';
|
|||||||
*/
|
*/
|
||||||
export function photorealUnavailableReason(hasCredentials) {
|
export function photorealUnavailableReason(hasCredentials) {
|
||||||
if (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');
|
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
@@ -2,14 +2,14 @@ import * as Cesium from 'cesium';
|
|||||||
export const MAP_STACKS = [
|
export const MAP_STACKS = [
|
||||||
{
|
{
|
||||||
id: 'photoreal',
|
id: 'photoreal',
|
||||||
label: 'Google 3D',
|
label: 'Google 3D', // i18n-ignore: imagery product name
|
||||||
shortLabel: '3D',
|
shortLabel: '3D',
|
||||||
kind: 'photoreal',
|
kind: 'photoreal',
|
||||||
requiresIon: false,
|
requiresIon: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bing-aerial',
|
id: 'bing-aerial',
|
||||||
label: 'Bing Aerial',
|
label: 'Bing Aerial', // i18n-ignore: imagery product name
|
||||||
shortLabel: 'Aerial',
|
shortLabel: 'Aerial',
|
||||||
kind: 'ion',
|
kind: 'ion',
|
||||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||||
@@ -17,7 +17,7 @@ export const MAP_STACKS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bing-labels',
|
id: 'bing-labels',
|
||||||
label: 'Bing Labels',
|
label: 'Bing Labels', // i18n-ignore: imagery product name
|
||||||
shortLabel: 'Labels',
|
shortLabel: 'Labels',
|
||||||
kind: 'ion',
|
kind: 'ion',
|
||||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||||
@@ -25,14 +25,14 @@ export const MAP_STACKS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'esri-imagery',
|
id: 'esri-imagery',
|
||||||
label: 'Esri Satellite',
|
label: 'Esri Satellite', // i18n-ignore: imagery product name
|
||||||
shortLabel: 'SAT',
|
shortLabel: 'SAT',
|
||||||
kind: 'esri-imagery',
|
kind: 'esri-imagery',
|
||||||
requiresIon: false,
|
requiresIon: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'osm',
|
id: 'osm',
|
||||||
label: 'OSM',
|
label: 'OSM', // i18n-ignore: imagery product name
|
||||||
shortLabel: 'OSM',
|
shortLabel: 'OSM',
|
||||||
kind: 'osm',
|
kind: 'osm',
|
||||||
requiresIon: false,
|
requiresIon: false,
|
||||||
|
|||||||
@@ -6,7 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Guidance shown when the active map stack cannot host draped imagery. */
|
/** 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.
|
* Resolve the imagery layer collection for the active map stack.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
placementVariants,
|
placementVariants,
|
||||||
} from './worldOverlayDraw.js';
|
} from './worldOverlayDraw.js';
|
||||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module worldOverlay
|
* @module worldOverlay
|
||||||
@@ -1269,7 +1270,7 @@ function ensureOverlayDom() {
|
|||||||
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
|
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
|
||||||
_accessibilityRoot.className = 'world-overlay-accessibility';
|
_accessibilityRoot.className = 'world-overlay-accessibility';
|
||||||
_accessibilityRoot.setAttribute('role', 'region');
|
_accessibilityRoot.setAttribute('role', 'region');
|
||||||
_accessibilityRoot.setAttribute('aria-label', 'Visible map targets');
|
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
|
||||||
document.body.appendChild(_accessibilityRoot);
|
document.body.appendChild(_accessibilityRoot);
|
||||||
}
|
}
|
||||||
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
|
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
|
||||||
@@ -2419,7 +2420,9 @@ function syncAccessibleActions() {
|
|||||||
if (!activate) return;
|
if (!activate) return;
|
||||||
const accepted = activate();
|
const accepted = activate();
|
||||||
if (accepted !== false && _accessibilityStatus) {
|
if (accepted !== false && _accessibilityStatus) {
|
||||||
_accessibilityStatus.textContent = `Focusing ${item.label}`;
|
_accessibilityStatus.textContent = t('Focusing {label}', {
|
||||||
|
label: item.label,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_accessibilityList.appendChild(button);
|
_accessibilityList.appendChild(button);
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ function buildWorkload(count) {
|
|||||||
id: `mover-${index}`,
|
id: `mover-${index}`,
|
||||||
position: () => position,
|
position: () => position,
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: `TRK ${1000 + index}`,
|
title: `TRK ${1000 + index}`,
|
||||||
details: [`ALT ${10_000 + index * 25}`],
|
details: [`ALT ${10_000 + index * 25}`],
|
||||||
priority: index % 7,
|
priority: index % 7,
|
||||||
@@ -461,6 +462,7 @@ function buildPhase3FirmsWorkload(count) {
|
|||||||
position,
|
position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: '224, 82, 82',
|
accent: '224, 82, 82',
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: `▲ ${50 + fireIndex} MW`,
|
title: `▲ ${50 + fireIndex} MW`,
|
||||||
details: ['high · 2h · N20'],
|
details: ['high · 2h · N20'],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -551,6 +553,7 @@ function buildPhase3VesselsWorkload(count) {
|
|||||||
position,
|
position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: '224, 82, 82',
|
accent: '224, 82, 82',
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: `▲ ${50 + fireIndex} MW`,
|
title: `▲ ${50 + fireIndex} MW`,
|
||||||
details: ['high · 2h · N20'],
|
details: ['high · 2h · N20'],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -570,6 +573,7 @@ function buildPhase3VesselsWorkload(count) {
|
|||||||
position,
|
position,
|
||||||
gapPx: selected ? 12 : 10,
|
gapPx: selected ? 12 : 10,
|
||||||
accent: '57, 213, 255',
|
accent: '57, 213, 255',
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
|
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
|
||||||
details: selected
|
details: selected
|
||||||
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
|
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
|
||||||
@@ -644,6 +648,7 @@ function buildPhase3TrackedWorkload(count) {
|
|||||||
gevTrackedId: 'flights:allocation-probe',
|
gevTrackedId: 'flights:allocation-probe',
|
||||||
gevDisplayPosition: selected.position,
|
gevDisplayPosition: selected.position,
|
||||||
gevLabelModel: {
|
gevLabelModel: {
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: 'ALLOC01',
|
title: 'ALLOC01',
|
||||||
details: ['FL350 · 451 kts', 'TEST AIR · A320'],
|
details: ['FL350 · 451 kts', 'TEST AIR · A320'],
|
||||||
accent: '#39d0ff',
|
accent: '#39d0ff',
|
||||||
@@ -694,6 +699,7 @@ function buildPhase4CctvWorkload(count) {
|
|||||||
const entry = createCctvThumbnailOverlayEntry({
|
const entry = createCctvThumbnailOverlayEntry({
|
||||||
id: active ? 'cctv:active' : `cctv:${index}`,
|
id: active ? 'cctv:active' : `cctv:${index}`,
|
||||||
position,
|
position,
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`,
|
title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`,
|
||||||
frameSlot: slot,
|
frameSlot: slot,
|
||||||
rank: index,
|
rank: index,
|
||||||
@@ -867,6 +873,7 @@ function buildPhase5CivilWorkload(count) {
|
|||||||
const entry = registration?.entries[0];
|
const entry = registration?.entries[0];
|
||||||
if (!entry)
|
if (!entry)
|
||||||
throw new Error('phase5-civil requires the protected tracked 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.title = 'ALLOC01 · FL350 · 451 kts';
|
||||||
entry.details = ['TEST AIR · A320', 'AUS → LAX'];
|
entry.details = ['TEST AIR · A320', 'AUS → LAX'];
|
||||||
return workload;
|
return workload;
|
||||||
@@ -881,6 +888,7 @@ function buildPhase5MilitaryWorkload(count) {
|
|||||||
if (!entry)
|
if (!entry)
|
||||||
throw new Error('phase5-military requires the protected tracked entry');
|
throw new Error('phase5-military requires the protected tracked entry');
|
||||||
entry.id = 'military:allocation-probe';
|
entry.id = 'military:allocation-probe';
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
entry.title = 'RCH451';
|
entry.title = 'RCH451';
|
||||||
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
|
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
|
||||||
entry.accent = '#ffd166';
|
entry.accent = '#ffd166';
|
||||||
@@ -1067,6 +1075,7 @@ function appendSubmarineCableWorkload(workload, count) {
|
|||||||
id: `${kind}-reference-${index}-alloc`,
|
id: `${kind}-reference-${index}-alloc`,
|
||||||
kind,
|
kind,
|
||||||
label:
|
label:
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
|
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
|
||||||
tip: position,
|
tip: position,
|
||||||
distanceM: 200_000 + index * 40_000,
|
distanceM: 200_000 + index * 40_000,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { renderPackGeometry } from './geometry.js';
|
import { renderPackGeometry } from './geometry.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
function caption(viewer, pack) {
|
function caption(viewer, pack) {
|
||||||
const card = document.createElement('section');
|
const card = document.createElement('section');
|
||||||
@@ -18,7 +19,7 @@ function caption(viewer, pack) {
|
|||||||
card.append(title);
|
card.append(title);
|
||||||
if (pack.attribution.url) {
|
if (pack.attribution.url) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.textContent = 'Source';
|
link.textContent = t('Source');
|
||||||
link.href = pack.attribution.url;
|
link.href = pack.attribution.url;
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
link.rel = 'noopener noreferrer';
|
link.rel = 'noopener noreferrer';
|
||||||
|
|||||||
+66
-45
@@ -23,10 +23,12 @@ import * as Cesium from 'cesium';
|
|||||||
import {
|
import {
|
||||||
SCENE_APPEND_RECIPES,
|
SCENE_APPEND_RECIPES,
|
||||||
SCENE_RECIPES,
|
SCENE_RECIPES,
|
||||||
|
displaySceneTitle,
|
||||||
|
displayShotTitle,
|
||||||
getSceneAppendRecipeById,
|
getSceneAppendRecipeById,
|
||||||
} from './recipes.js';
|
} from './recipes.js';
|
||||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.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 { createSceneDataPacks } from './dataPacks/controller.js';
|
||||||
import { createSceneInteractions } from './interactions.js';
|
import { createSceneInteractions } from './interactions.js';
|
||||||
import { createSceneSharing } from './sharing.js';
|
import { createSceneSharing } from './sharing.js';
|
||||||
@@ -66,26 +68,7 @@ import {
|
|||||||
// project titles, and voice-matching titles all stay English (keep-English
|
// project titles, and voice-matching titles all stay English (keep-English
|
||||||
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
||||||
// localizes, so an operator's renamed scene is never masked by the catalog.
|
// localizes, so an operator's renamed scene is never masked by the catalog.
|
||||||
const RECIPE_TITLE_KEYS = Object.freeze({
|
// Recipe and built-in shot titles are marked with msg() in recipes.js.
|
||||||
'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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @constant {string} localStorage key for the serialized project */
|
/** @constant {string} localStorage key for the serialized project */
|
||||||
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
|
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
|
||||||
@@ -397,8 +380,8 @@ export class SceneDirector {
|
|||||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||||
if (type === 'shot-loaded')
|
if (type === 'shot-loaded')
|
||||||
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
||||||
scene: scene.title,
|
scene: displaySceneTitle(scene),
|
||||||
shot: shot.title,
|
shot: displayShotTitle(shot.title),
|
||||||
});
|
});
|
||||||
this._publish({
|
this._publish({
|
||||||
type,
|
type,
|
||||||
@@ -419,10 +402,15 @@ export class SceneDirector {
|
|||||||
subscribe: (listener) => this.subscribe(listener),
|
subscribe: (listener) => this.subscribe(listener),
|
||||||
read: () => ({
|
read: () => ({
|
||||||
// Scene titles surface in the panel through SceneControls; built-in
|
// 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) => ({
|
scenes: this._project.scenes.map((scene) => ({
|
||||||
...scene,
|
...scene,
|
||||||
title: displaySceneTitle(scene),
|
title: displaySceneTitle(scene),
|
||||||
|
shots: scene.shots.map((shot) => ({
|
||||||
|
...shot,
|
||||||
|
displayTitle: displayShotTitle(shot.title),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
selectedSceneId: this._selectedSceneId,
|
selectedSceneId: this._selectedSceneId,
|
||||||
selectedShotId: this._selectedShotId,
|
selectedShotId: this._selectedShotId,
|
||||||
@@ -519,6 +507,7 @@ export class SceneDirector {
|
|||||||
|
|
||||||
const scene = {
|
const scene = {
|
||||||
id: uid('scene'),
|
id: uid('scene'),
|
||||||
|
// i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it.
|
||||||
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
|
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
|
||||||
shots: [],
|
shots: [],
|
||||||
};
|
};
|
||||||
@@ -797,7 +786,9 @@ export class SceneDirector {
|
|||||||
));
|
));
|
||||||
if (!inventoryMatches) {
|
if (!inventoryMatches) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
`Cannot update ${recipe.title}: shot inventory changed`,
|
t('Cannot update {scene}: shot inventory changed', {
|
||||||
|
scene: t(recipe.title),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
appended: false,
|
appended: false,
|
||||||
@@ -828,7 +819,9 @@ export class SceneDirector {
|
|||||||
);
|
);
|
||||||
if (!sourcePackMatches) {
|
if (!sourcePackMatches) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
`Cannot update ${recipe.title}: evidence beats changed`,
|
t('Cannot update {scene}: evidence beats changed', {
|
||||||
|
scene: t(recipe.title),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
appended: false,
|
appended: false,
|
||||||
@@ -851,7 +844,9 @@ export class SceneDirector {
|
|||||||
}));
|
}));
|
||||||
if (resolvedPatches.some(({ shot }) => !shot)) {
|
if (resolvedPatches.some(({ shot }) => !shot)) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
`Cannot update ${recipe.title}: shot bindings are incomplete`,
|
t('Cannot update {scene}: shot bindings are incomplete', {
|
||||||
|
scene: t(recipe.title),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
appended: false,
|
appended: false,
|
||||||
@@ -931,8 +926,18 @@ export class SceneDirector {
|
|||||||
if (announce) {
|
if (announce) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
marker
|
marker
|
||||||
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
|
? tn(
|
||||||
: `Appended ${appendedShots.length} shots: ${recipe.title}`,
|
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 {
|
return {
|
||||||
@@ -961,9 +966,8 @@ export class SceneDirector {
|
|||||||
const shot = normalizeShot(
|
const shot = normalizeShot(
|
||||||
{
|
{
|
||||||
id: uid('shot'),
|
id: uid('shot'),
|
||||||
title: t('Shot {n}', {
|
// i18n-ignore: stored English; displayShotTitle translates it when shown.
|
||||||
n: scene.shots.length + 1,
|
title: `Shot ${scene.shots.length + 1}`,
|
||||||
}),
|
|
||||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||||
holdSec: DEFAULT_HOLD_SEC,
|
holdSec: DEFAULT_HOLD_SEC,
|
||||||
camera,
|
camera,
|
||||||
@@ -979,8 +983,8 @@ export class SceneDirector {
|
|||||||
this._shotOutcome('shot-captured', scene, shot);
|
this._shotOutcome('shot-captured', scene, shot);
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
t('Captured: {scene} / {shot}', {
|
t('Captured: {scene} / {shot}', {
|
||||||
scene: scene.title,
|
scene: displaySceneTitle(scene),
|
||||||
shot: shot.title,
|
shot: displayShotTitle(shot.title),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1012,8 +1016,8 @@ export class SceneDirector {
|
|||||||
this._shotOutcome('shot-updated', scene, shot);
|
this._shotOutcome('shot-updated', scene, shot);
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
t('Updated: {scene} / {shot}', {
|
t('Updated: {scene} / {shot}', {
|
||||||
scene: scene.title,
|
scene: displaySceneTitle(scene),
|
||||||
shot: shot.title,
|
shot: displayShotTitle(shot.title),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1121,7 +1125,11 @@ export class SceneDirector {
|
|||||||
const released = await this._releaseSceneLayers(previousScene, token);
|
const released = await this._releaseSceneLayers(previousScene, token);
|
||||||
if (!released || token.cancelled) {
|
if (!released || token.cancelled) {
|
||||||
if (!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;
|
return;
|
||||||
}
|
}
|
||||||
this._loadedSceneId = null;
|
this._loadedSceneId = null;
|
||||||
@@ -1171,7 +1179,12 @@ export class SceneDirector {
|
|||||||
seeking: true,
|
seeking: true,
|
||||||
});
|
});
|
||||||
if (this._loadAbort === controller) this._loadAbort = null;
|
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);
|
this._activateInteractions(scene, shot);
|
||||||
return { started: true, shotId };
|
return { started: true, shotId };
|
||||||
}
|
}
|
||||||
@@ -1533,7 +1546,12 @@ export class SceneDirector {
|
|||||||
running: false,
|
running: false,
|
||||||
seeking: true,
|
seeking: true,
|
||||||
});
|
});
|
||||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
this._updateStatus(
|
||||||
|
t('Seeked: {scene} / {shot}', {
|
||||||
|
scene: displaySceneTitle(scene),
|
||||||
|
shot: displayShotTitle(shot.title),
|
||||||
|
}),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,6 +1825,7 @@ export class SceneDirector {
|
|||||||
// Initialize telemetry accumulator for this run
|
// Initialize telemetry accumulator for this run
|
||||||
this._activeRun = {
|
this._activeRun = {
|
||||||
recipeId: `project-${PROJECT_VERSION}`,
|
recipeId: `project-${PROJECT_VERSION}`,
|
||||||
|
// i18n-ignore: run telemetry record, downloaded as JSON
|
||||||
title: 'Editable Scene Run',
|
title: 'Editable Scene Run',
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
estimatedDurationSec,
|
estimatedDurationSec,
|
||||||
@@ -1950,7 +1969,7 @@ export class SceneDirector {
|
|||||||
if (action.type === 'shot') {
|
if (action.type === 'shot') {
|
||||||
if (this._interactionTransitions >= 64) {
|
if (this._interactionTransitions >= 64) {
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
'Scene transition limit reached — load a shot to reset',
|
t('Scene transition limit reached — load a shot to reset'),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1993,7 +2012,7 @@ export class SceneDirector {
|
|||||||
try {
|
try {
|
||||||
payload = stringifySceneDocument(this._project);
|
payload = stringifySceneDocument(this._project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._updateStatus(`Export failed: ${error.message}`);
|
this._updateStatus(t('Export failed: {error}', { error: error.message }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Trigger a browser download via a temporary anchor element
|
// Trigger a browser download via a temporary anchor element
|
||||||
@@ -2066,7 +2085,7 @@ export class SceneDirector {
|
|||||||
this._loadedSceneId = null;
|
this._loadedSceneId = null;
|
||||||
this._saveProject();
|
this._saveProject();
|
||||||
this._publish({ type: 'project-imported', project });
|
this._publish({ type: 'project-imported', project });
|
||||||
this._updateStatus(`Imported ${file.name}`);
|
this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -2077,8 +2096,8 @@ export class SceneDirector {
|
|||||||
return false;
|
return false;
|
||||||
this._updateStatus(
|
this._updateStatus(
|
||||||
error instanceof SceneDocumentError
|
error instanceof SceneDocumentError
|
||||||
? `Import failed: ${error.message}`
|
? t('Import failed: {error}', { error: error.message })
|
||||||
: 'Import failed (could not read JSON file)',
|
: t('Import failed (could not read JSON file)'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2178,7 +2197,9 @@ export class SceneDirector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (refused.length) {
|
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] });
|
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
|
||||||
}
|
}
|
||||||
return { applied, refused, cancelled: false };
|
return { applied, refused, cancelled: false };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../data/inputOwnership.js';
|
import { isPointerFree } from '../data/inputOwnership.js';
|
||||||
import { createInteractionSession } from '../director/interactions/session.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. */
|
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
|
||||||
export function createSceneInteractions(
|
export function createSceneInteractions(
|
||||||
@@ -24,7 +25,7 @@ export function createSceneInteractions(
|
|||||||
card.append(text);
|
card.append(text);
|
||||||
if (item.action.url) {
|
if (item.action.url) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.textContent = 'Source';
|
link.textContent = t('Source');
|
||||||
link.style.color = '#6eeaff';
|
link.style.color = '#6eeaff';
|
||||||
link.href = item.action.url;
|
link.href = item.action.url;
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
@@ -60,8 +61,8 @@ export function createSceneInteractions(
|
|||||||
const ok = await session.dispatch(id);
|
const ok = await session.dispatch(id);
|
||||||
if (current && current === panel && status)
|
if (current && current === panel && status)
|
||||||
status.textContent = ok
|
status.textContent = ok
|
||||||
? 'Action complete'
|
? t('Action complete')
|
||||||
: 'Action unavailable or cancelled';
|
: t('Action unavailable or cancelled');
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -87,7 +88,7 @@ export function createSceneInteractions(
|
|||||||
}
|
}
|
||||||
panel = document.createElement('section');
|
panel = document.createElement('section');
|
||||||
panel.dataset.directorInteractions = '';
|
panel.dataset.directorInteractions = '';
|
||||||
panel.setAttribute('aria-label', 'Scene actions');
|
panel.setAttribute('aria-label', t('Scene actions'));
|
||||||
Object.assign(panel.style, {
|
Object.assign(panel.style, {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '12px',
|
left: '12px',
|
||||||
@@ -104,12 +105,13 @@ export function createSceneInteractions(
|
|||||||
});
|
});
|
||||||
const owner = panel;
|
const owner = panel;
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.textContent = 'Scene actions';
|
title.textContent = t('Scene actions');
|
||||||
panel.append(title);
|
panel.append(title);
|
||||||
status = document.createElement('p');
|
status = document.createElement('p');
|
||||||
status.setAttribute('role', 'status');
|
status.setAttribute('role', 'status');
|
||||||
status.textContent =
|
status.textContent = t(
|
||||||
'Select a feature or use Tab and Enter to choose an action.';
|
'Select a feature or use Tab and Enter to choose an action.',
|
||||||
|
);
|
||||||
panel.append(status);
|
panel.append(status);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
@@ -157,7 +159,12 @@ export function createSceneInteractions(
|
|||||||
? '2px solid #6eeaff'
|
? '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 });
|
buttons.get(matches[0].id)?.focus({ preventScroll: true });
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
|
|||||||
const ADDITIONAL_SHOTS = [
|
const ADDITIONAL_SHOTS = [
|
||||||
{
|
{
|
||||||
id: 'second-landslide',
|
id: 'second-landslide',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Second landslide',
|
title: 'Second landslide',
|
||||||
lat: 28.399234,
|
lat: 28.399234,
|
||||||
lon: 85.535806,
|
lon: 85.535806,
|
||||||
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dhunche',
|
id: 'dhunche',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Dhunche',
|
title: 'Dhunche',
|
||||||
lat: 28.161431,
|
lat: 28.161431,
|
||||||
lon: 85.324278,
|
lon: 85.324278,
|
||||||
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mailung-upper-trishuli',
|
id: 'mailung-upper-trishuli',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Mailung / Upper Trishuli bridge',
|
title: 'Mailung / Upper Trishuli bridge',
|
||||||
lat: 28.140642,
|
lat: 28.140642,
|
||||||
lon: 85.265996,
|
lon: 85.265996,
|
||||||
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mailung-bazzar',
|
id: 'mailung-bazzar',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Mailung Bazzar, Dandaguan',
|
title: 'Mailung Bazzar, Dandaguan',
|
||||||
lat: 28.126526,
|
lat: 28.126526,
|
||||||
lon: 85.248027,
|
lon: 85.248027,
|
||||||
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dandagaun',
|
id: 'dandagaun',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Dandagaun, Rasuwa',
|
title: 'Dandagaun, Rasuwa',
|
||||||
lat: 28.124424,
|
lat: 28.124424,
|
||||||
lon: 85.245154,
|
lon: 85.245154,
|
||||||
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dandagaun-viewpoint',
|
id: 'dandagaun-viewpoint',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Dandagaun viewpoint restaurant',
|
title: 'Dandagaun viewpoint restaurant',
|
||||||
lat: 28.086663,
|
lat: 28.086663,
|
||||||
lon: 85.229914,
|
lon: 85.229914,
|
||||||
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'betrawati-bazaar',
|
id: 'betrawati-bazaar',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Betrawati Bazaar',
|
title: 'Betrawati Bazaar',
|
||||||
lat: 28.029827,
|
lat: 28.029827,
|
||||||
lon: 85.227694,
|
lon: 85.227694,
|
||||||
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhainse',
|
id: 'bhainse',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Bhainse',
|
title: 'Bhainse',
|
||||||
lat: 28.023087,
|
lat: 28.023087,
|
||||||
lon: 85.229449,
|
lon: 85.229449,
|
||||||
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'devighat-taadi-khola-bridge',
|
id: 'devighat-taadi-khola-bridge',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Devighat / Taadi Khola Bridge',
|
title: 'Devighat / Taadi Khola Bridge',
|
||||||
lat: 27.921132,
|
lat: 27.921132,
|
||||||
lon: 85.177113,
|
lon: 85.177113,
|
||||||
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'charaudi',
|
id: 'charaudi',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Charaudi',
|
title: 'Charaudi',
|
||||||
lat: 27.865095,
|
lat: 27.865095,
|
||||||
lon: 84.838424,
|
lon: 84.838424,
|
||||||
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'final-view',
|
id: 'final-view',
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Final view',
|
title: 'Final view',
|
||||||
lat: 28.0834,
|
lat: 28.0834,
|
||||||
lon: 85.206,
|
lon: 85.206,
|
||||||
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
|
|||||||
shotPatches: [
|
shotPatches: [
|
||||||
...(base.shotPatches || []),
|
...(base.shotPatches || []),
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||||
title: 'Final view',
|
title: 'Final view',
|
||||||
layers: finalView.layers,
|
layers: finalView.layers,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Connect the portable Director runner to the existing scene presentation. */
|
/** Connect the portable Director runner to the existing scene presentation. */
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
|
import { displaySceneTitle, displayShotTitle } from './recipes.js';
|
||||||
|
|
||||||
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||||
let timing;
|
let timing;
|
||||||
@@ -19,8 +20,8 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
|||||||
t('Running {index}/{total}: {scene} / {shot}', {
|
t('Running {index}/{total}: {scene} / {shot}', {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
total,
|
total,
|
||||||
scene: scene.title,
|
scene: displaySceneTitle(scene),
|
||||||
shot: shot.title,
|
shot: displayShotTitle(shot.title),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
director._logEvent('shot_start', {
|
director._logEvent('shot_start', {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function recipeToScene(recipe) {
|
|||||||
const mapStack = keyframe.mapStack || post.mapStack;
|
const mapStack = keyframe.mapStack || post.mapStack;
|
||||||
return {
|
return {
|
||||||
id: uid('shot'),
|
id: uid('shot'),
|
||||||
|
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||||
title: keyframe.title || `Shot ${idx + 1}`,
|
title: keyframe.title || `Shot ${idx + 1}`,
|
||||||
durationSec: Math.max(
|
durationSec: Math.max(
|
||||||
0.2,
|
0.2,
|
||||||
@@ -171,6 +172,7 @@ export function recipeToScene(recipe) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: recipe.id || uid('scene'),
|
id: recipe.id || uid('scene'),
|
||||||
|
// i18n-ignore: persisted fallback; built-in titles display through director
|
||||||
title: recipe.title || 'Untitled Scene',
|
title: recipe.title || 'Untitled Scene',
|
||||||
releaseLayerIds: [
|
releaseLayerIds: [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -225,6 +227,7 @@ export function normalizeShot(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: rawShot?.id || uid('shot'),
|
id: rawShot?.id || uid('shot'),
|
||||||
|
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||||
title: rawShot?.title || `Shot ${index + 1}`,
|
title: rawShot?.title || `Shot ${index + 1}`,
|
||||||
durationSec: Math.max(
|
durationSec: Math.max(
|
||||||
0.2,
|
0.2,
|
||||||
@@ -349,6 +352,7 @@ export function normalizeProject(rawProject) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: scene?.id || uid('scene'),
|
id: scene?.id || uid('scene'),
|
||||||
|
// i18n-ignore: persisted fallback for a saved scene without a title
|
||||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||||
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
||||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||||
|
|||||||
+88
-7
@@ -4,11 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
||||||
|
import { msg, t } from '../i18n/index.js';
|
||||||
|
|
||||||
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||||
id: 'bhote-koshi-nepal-evidence-pack',
|
id: 'bhote-koshi-nepal-evidence-pack',
|
||||||
version: 12,
|
version: 12,
|
||||||
title: 'Bhote Koshi Evidence Sequence',
|
title: msg('Bhote Koshi Evidence Sequence'),
|
||||||
style: 'normal',
|
style: 'normal',
|
||||||
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
||||||
layers: {
|
layers: {
|
||||||
@@ -26,6 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
|||||||
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Global Incident Context',
|
title: 'Global Incident Context',
|
||||||
lat: 27.5962,
|
lat: 27.5962,
|
||||||
lon: 78.5718,
|
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',
|
title: 'Nepal-Focused Globe Rotation',
|
||||||
lat: 28.7248,
|
lat: 28.7248,
|
||||||
lon: 81.3226,
|
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',
|
title: 'Bhote Koshi Regional Approach',
|
||||||
lat: 27.0354,
|
lat: 27.0354,
|
||||||
lon: 84.6287,
|
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',
|
title: 'Bhote Koshi Nearby Cities',
|
||||||
lat: 27.891,
|
lat: 27.891,
|
||||||
lon: 85.428,
|
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',
|
title: 'Bhote Koshi Incident Corridor',
|
||||||
lat: 28.0529,
|
lat: 28.0529,
|
||||||
lon: 85.2189,
|
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',
|
title: 'Bhote Koshi Flood Path',
|
||||||
lat: 28.1691,
|
lat: 28.1691,
|
||||||
lon: 85.352,
|
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',
|
title: 'Bhote Koshi Corridor Overview',
|
||||||
lat: 27.8986,
|
lat: 27.8986,
|
||||||
lon: 84.8723,
|
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',
|
title: 'Bhote Koshi Upper Valley',
|
||||||
lat: 28.4187,
|
lat: 28.4187,
|
||||||
lon: 85.4341,
|
lon: 85.4341,
|
||||||
@@ -221,6 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
|||||||
},
|
},
|
||||||
shotPatches: [
|
shotPatches: [
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Global Incident Context',
|
title: 'Global Incident Context',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Nepal-Focused Globe Rotation',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Bhote Koshi Regional Approach',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Bhote Koshi Nearby Cities',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Bhote Koshi Incident Corridor',
|
||||||
camera: {
|
camera: {
|
||||||
lat: 28.0529,
|
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',
|
title: 'Bhote Koshi Flood Path',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Bhote Koshi Corridor Overview',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
layers: {
|
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',
|
title: 'Bhote Koshi Upper Valley',
|
||||||
holdSec: 8,
|
holdSec: 8,
|
||||||
visual: { mapStack: 'photoreal' },
|
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',
|
title: 'Upper Valley Collapse',
|
||||||
camera: {
|
camera: {
|
||||||
lat: 28.417,
|
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',
|
title: 'Nepal-China Border Gate',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Timure Evidence Cluster',
|
title: 'Timure Evidence Cluster',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Syabru Besi Passage',
|
title: 'Syabru Besi Passage',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Bidur / Trishuli Consequence',
|
title: 'Bidur / Trishuli Consequence',
|
||||||
visual: { mapStack: 'photoreal' },
|
visual: { mapStack: 'photoreal' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cameraPath: [
|
cameraPath: [
|
||||||
{
|
{
|
||||||
|
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||||
title: 'Upper Valley Collapse',
|
title: 'Upper Valley Collapse',
|
||||||
lat: 28.417,
|
lat: 28.417,
|
||||||
lon: 85.3937,
|
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',
|
title: 'Debris-Dammed Lake',
|
||||||
lat: 28.392914,
|
lat: 28.392914,
|
||||||
lon: 85.570921,
|
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',
|
title: 'Nepal-China Border Gate',
|
||||||
mapStack: 'photoreal',
|
mapStack: 'photoreal',
|
||||||
lat: 28.316789,
|
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',
|
title: 'Timure Evidence Cluster',
|
||||||
mapStack: 'photoreal',
|
mapStack: 'photoreal',
|
||||||
lat: 28.285968,
|
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',
|
title: 'Syabru Besi Passage',
|
||||||
mapStack: 'photoreal',
|
mapStack: 'photoreal',
|
||||||
lat: 28.210306,
|
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',
|
title: 'Bidur / Trishuli Consequence',
|
||||||
mapStack: 'photoreal',
|
mapStack: 'photoreal',
|
||||||
lat: 27.981737,
|
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
|
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||||
? Object.freeze({
|
? Object.freeze({
|
||||||
id: 'bhote-koshi-nepal-scene',
|
id: 'bhote-koshi-nepal-scene',
|
||||||
title: 'Nepal Flood Incident',
|
title: msg('Nepal Flood Incident'),
|
||||||
durationSec: 15,
|
durationSec: 15,
|
||||||
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
||||||
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
|
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)
|
.slice(0, 3)
|
||||||
.map((keyframe, index) => ({
|
.map((keyframe, index) => ({
|
||||||
...keyframe,
|
...keyframe,
|
||||||
|
// i18n-ignore: legacy shot titles the bootstrap matches verbatim
|
||||||
title: `Shot ${index + 1}`,
|
title: `Shot ${index + 1}`,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@@ -593,7 +622,7 @@ const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
|
|||||||
const PUBLIC_SCENE_RECIPES = [
|
const PUBLIC_SCENE_RECIPES = [
|
||||||
{
|
{
|
||||||
id: 'flights-radar',
|
id: 'flights-radar',
|
||||||
title: 'Global Flights Radar',
|
title: msg('Global Flights Radar'),
|
||||||
durationSec: 30,
|
durationSec: 30,
|
||||||
style: 'retro',
|
style: 'retro',
|
||||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
|
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
|
||||||
@@ -663,7 +692,7 @@ const PUBLIC_SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'orbital-watch',
|
id: 'orbital-watch',
|
||||||
title: 'Orbital Watch',
|
title: msg('Orbital Watch'),
|
||||||
durationSec: 32,
|
durationSec: 32,
|
||||||
style: 'surveillance',
|
style: 'surveillance',
|
||||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||||
@@ -741,7 +770,7 @@ const PUBLIC_SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'thermal-threats',
|
id: 'thermal-threats',
|
||||||
title: 'Thermal Threat Board',
|
title: msg('Thermal Threat Board'),
|
||||||
durationSec: 26,
|
durationSec: 26,
|
||||||
style: 'thermal',
|
style: 'thermal',
|
||||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||||
@@ -809,7 +838,7 @@ const PUBLIC_SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'city-overload',
|
id: 'city-overload',
|
||||||
title: 'City Overload',
|
title: msg('City Overload'),
|
||||||
durationSec: 30,
|
durationSec: 30,
|
||||||
style: 'surveillance',
|
style: 'surveillance',
|
||||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
|
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
|
||||||
@@ -887,7 +916,7 @@ const PUBLIC_SCENE_RECIPES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'omniscience-pullback',
|
id: 'omniscience-pullback',
|
||||||
title: 'Omniscience Pullback',
|
title: msg('Omniscience Pullback'),
|
||||||
durationSec: 36,
|
durationSec: 36,
|
||||||
style: 'retro',
|
style: 'retro',
|
||||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||||
@@ -982,3 +1011,55 @@ export function getSceneRecipeById(id) {
|
|||||||
export function getSceneAppendRecipeById(id) {
|
export function getSceneAppendRecipeById(id) {
|
||||||
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
|
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
Reference in new issue
Block a user