Merge pull request #602 from meirm/i18n-spanish

i18n: interface localization with Spanish and Russian (English-text keys)
This commit is contained in:
German Khokhlov authored and GitHub committed 2026-10-16 21:43:26 +04:00
commit e8b3a10a07
407 files changed
+29542 -4338

No files matched your search

+12
View File
@@ -170,6 +170,18 @@
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
unchanged; extra cameras share it round-robin with the other packs.
- Spanish interface (`es`), translated by @meirm (#602) and reviewed against the
current English.
- Interface localization, starting with Russian. UI text is marked with
`t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed
by the English text itself; each language is one flat JSON catalog
(`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language
picker (the globe button in the bottom-right corner), then the browser
language; English stays the default and the fallback. `npm run i18n`, also
run by `npm test`, fails on missing, unused or broken translations. Built on
the i18n foundation by @meirm (#601), with the Russian catalog started
from #312. It also fails on UI text that is not marked at all.
- Street Level counts unique tiles before ranking so overlapping dateline halves
in polar views do not waste slots in the 16-tile overview budget.
+1
View File
@@ -126,6 +126,7 @@ The highest-leverage places to jump in:
- ES modules, **2-space indent, single quotes, semicolons.**
- JSDoc on exported/public functions.
- Match the surrounding code — comment density, naming, and idiom.
- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
## Formatting and reusable components
+33
View File
@@ -3228,6 +3228,39 @@ test:track` 43 tracking invariants · headless QA harnesses under
> attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`.
> The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history.
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
translated later. Markup opts in with `data-i18n` (element text) and
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first
when the reload would lose work registered with `registerUnsavedWork`:
unsaved keys or CCTV calibration, Draw marks, imported map files, the open
cockpit, a voice session.
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or
wrongly-shaped plural entries.
- **Not translated by design:** layer ids, share-link and hash parameters, API
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
station names) and attribution.
- **Unmarked text.** The same check lists UI text that is not marked at all
(markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`)
and fails on it. Text kept on purpose says why: `translate="no"`,
`data-i18n-runtime`, or `// i18n-ignore: <reason>`. `src/tools/` is skipped:
it writes for AI clients, not the page.
## Canonical Docs Order
Use docs in this order when details conflict:
+92
View File
@@ -0,0 +1,92 @@
# UI text and translation
Every piece of text a user can see goes through `t()`, `tn()` or `msg()` in code, or a `data-i18n*` attribute in markup. The English text itself is the key. English needs no catalog; other languages live in `src/i18n/locales/<code>.json`. Translators work from [TRANSLATORS.md](./TRANSLATORS.md).
## Which one to use
| | Translates | Use for |
| ------------------------------- | --------------------------------- | --------------------------------------------- |
| `t(text, values)` | now | any text shown right where it is written |
| `tn(count, one, other, values)` | now, picking the form for `count` | text that changes with a number |
| `msg(text)` | never: returns `text` unchanged | text stored in data and passed to `t()` later |
```js
import { t, tn, msg } from '../i18n/index.js';
status.textContent = t('Save keys');
status.textContent = t('Saved to {store}', { store: storeLabel() });
chip.textContent = tn(missing, '{count} KEY WAITING', '{count} KEYS WAITING');
// en: 1 KEY / 3 KEYS ru: 1 КЛЮЧ / 3 КЛЮЧА / 5 КЛЮЧЕЙ
const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') };
label.textContent = t(FEED_STATE_LABELS[state]);
```
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
## Markup
```html
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
<input placeholder="Search any location..." data-i18n-placeholder />
```
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
## Rules
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
- **Use placeholders, not concatenation.** `t('Flying to {place}…', { place })`, not `t('Flying to') + ' ' + place`. Word order differs between languages.
- **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms.
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
- **Numbers and dates** go through `formatNumber`, `formatDate`, `formatRelativeTime` and `formatUtcDayMonth` from `src/i18n/index.js`, which follow the active language.
- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
## After you add or change text
```sh
npm run i18n # what each language lacks or no longer uses, and unmarked UI text
npm run i18n -- --missing ru # the missing ru entries as JSON
npm run i18n -- --prune # drop entries whose English no longer exists
```
Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete.
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`.
## Text the check reports as unmarked
`npm run i18n` and `npm test` fail on UI text that is not marked:
- **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`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
Fix it by marking the text. When the text must stay as it is, say so where it lives:
| Case | How |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
## Where things live
| File | What it holds |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
+66
View File
@@ -0,0 +1,66 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
## How it works
The English text is the key: code calls `t('Save keys')`, and markup marks elements with `data-i18n`. How developers mark text is in [I18N.md](./I18N.md).
Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keyed by the English text:
```json
{
"Save keys": "Сохранить ключи",
"{count} cameras": {
"one": "{count} камера",
"few": "{count} камеры",
"many": "{count} камер",
"other": "{count} камеры"
}
}
```
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
## The check
```sh
npm run i18n # what is missing, unused or broken, per language
npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate
npm run i18n -- --prune # drop entries the code no longer uses
```
`npm test` runs the same check. It fails on a missing, empty or unused entry, on placeholders that differ from the English, on a missing plural form, and on text or plural forms still in English. The `--missing` template fails until every entry is translated. If an entry is right in English (a code such as CRT, a product name), 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.
## Fixing a translation
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
## Adding a language
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
## Rules
- Keep every `{placeholder}` name exactly as in the English: do not rename, drop or add one. Its position inside the sentence follows the grammar of your language, e.g. `"{accepted} of {raw} records accepted"` → `"принято записей: {accepted} из {raw}"`.
- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line.
- Keep the separators `·`, `—`, `→`, `↗`, `…`.
- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9).
- Units follow the language's convention; see the language notes.
## Never translated
These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content.
## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
+75
View File
@@ -0,0 +1,75 @@
# Spanish (`es`)
Catalog: `src/i18n/locales/es.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
## Style
- Neutral Spanish that reads naturally in Spain and Latin America; avoid regionalisms.
- Address the user with informal "tú" imperatives: «Añade», «Activa», «Elige», «Pega», «Inténtalo de nuevo». Tooltips that name an action may use the infinitive: «Expandir el panel», «Restablecer la cámara».
- Sentence case, not Title Case: "Live Flights" → «Vuelos en vivo». UPPERCASE English stays UPPERCASE: «EN VIVO», «SIN DATOS».
- Quotes inside Spanish text are straight double quotes: «¿Eliminar el plano "{shot}"?». Questions and exclamations open with ¿ and ¡.
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., SIG., VEL., DIST., SINCR., cám., cob.
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE and ALT stay in English everywhere, as in cockpit avionics. Words used in a sentence are translated: heading → «rumbo», range → «alcance».
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, H, 1–9 («Mantén pulsado Space para hablar»).
- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KN, FT, MIN, H. "m" is always metres, so minutes are «min» («hace 5 min»). MW stays MW. `°` and `%` stay.
- "ago" goes before the time: «hace {count} min». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «hace» again.
- Spelling: «costo» (not «coste»).
## Plurals
A `tn()` entry needs three forms. `Intl.PluralRules('es')` picks:
| Form | Counts | Example |
| ------- | -------------------------------------- | -------------------- |
| `one` | 1 | 1 cámara |
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de cámaras |
| `other` | everything else: 0, 2–999 999, 1.5… | 3 cámaras |
The `many` form is the `other` text with «de» after the number: «{count} de cámaras cargadas», «{count} DE CLAVES EN ESPERA».
## Glossary
| English | Español |
| --------------------------------------------- | ----------------------------------------------------------------- |
| layer / data layer | capa / capa de datos |
| feed / source / data source | fuente / fuente / fuente de datos |
| LIVE (status) / live data | EN VIVO / datos en vivo |
| SIMULATED | SIMULADO |
| stale / degraded / unavailable | desactualizado / degradado / no disponible |
| retry / retrying | reintento / reintentando |
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
| Provider Settings | Ajustes de proveedores |
| ON / OFF (state chips) | ACTIVO / INACTIVO (short enough for the layer pill) |
| enable / disable | activar / desactivar |
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares |
| vessel, ship | buque |
| satellite / shell (Starlink) / DENSE | satélite / capa / DENSO |
| launch (rocket) / launch site / space mission | lanzamiento / lugar de lanzamiento / misión espacial |
| ascent / replay / payload / stage | ascenso / repetición / carga útil / etapa |
| contact / tracking | contacto / rastreo |
| installation / site | instalación / sitio |
| mapped / observed | mapeado / observado |
| camera / CCTV camera | cámara / cámara CCTV |
| earthquake / active fire | terremoto / incendio activo |
| radio station | emisora |
| road / flow (traffic) | vía / flujo |
| scene / shot / scene recipe | escena / plano / receta de escena |
| briefing / panel / dock | información / panel / dock |
| cockpit | cabina |
| preset / visual preset | preajuste / preajuste visual |
| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura |
| feather / fade | difuminado / desvanecimiento |
| detection overlay | superposición de detección |
| pin (Draw tool) | marcador |
| Bloom / Glow (visual effect) | resplandor |
| Cyber (HUD layout, effect) | Ciber |
| heading, course / altitude / ground speed | rumbo / altitud / velocidad respecto al suelo (short: VEL. SUELO) |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
| PREV / NEXT | ANT. / SIG. |
| Previous / Next (button text) | Anterior / Siguiente |
| L / R (relative bearing) | I / D |
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTICIAS / LOCAL |
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO |
| click / click to … | haz clic / clic para … |
+65
View File
@@ -0,0 +1,65 @@
# Russian (`ru`)
Catalog: `src/i18n/locales/ru.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
## Style
- Address the user with polite imperatives: «Нажмите», «Включите», «Попробуйте снова».
- Sentence case, not Title Case: "Live Flights" → «Рейсы в реальном времени».
- Quotes inside Russian text are «ёлочки». Write ё where it belongs: её, ещё, всё.
- In short labels, a standard abbreviation with a period beats a wrapped line: НЕОПРЕД., СЛЕД.
- Units: км, м, км/ч, м/с, уз (knots), фт (feet), ч, мин, с; in UPPERCASE text КМ, М, КМ/С, УЗ, ФТ. MW is МВт in any case. `°` and `%` stay.
## Plurals
A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
| Form | Counts | Example |
| ------- | ------------------------ | ---------- |
| `one` | 1, 21, 31, 101… | 1 камера |
| `few` | 2–4, 22–24, 32–34… | 3 камеры |
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
| `other` | fractions: 1.5, 2.25… | 1,5 камеры |
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}».
## Glossary
| English | Русский |
| ------------------------------------------- | ------------------------------------------------------------------ |
| layer / data layer | слой / слой данных |
| feed | поток данных (short: поток) |
| source / data source | источник / источник данных |
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
| SIMULATED | СИМУЛЯЦИЯ |
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
| retry | повтор |
| key (API key) / POWER UP | ключ / ЗАПУСК |
| Provider Settings | Настройки провайдеров |
| globe / map / map source | глобус / карта / источник карты |
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
| vessel, ship / vessels | судно / суда |
| satellite | спутник |
| launch (rocket) / space mission | пуск / космическая миссия |
| contact / tracking | контакт / отслеживание |
| installation, site | объект |
| camera / CCTV camera | камера / камера CCTV |
| earthquake / active fire | землетрясение / активный пожар |
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
| briefing / panel / dock | брифинг / панель / док |
| preset / visual preset | пресет / визуальный пресет |
| Scope (circular viewport mask) | прицел |
| heading / altitude / ground speed | курс / высота / путевая скорость |
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке |
| Street View (Google) | Просмотр улиц (как в русских Google Картах) |
| CONUS (NOAA region code) | CONUS, без перевода |
| WHOT/BHOT, Ironbow (thermal modes, palette) | без перевода |
| PREV / NEXT | ПРЕД. / СЛЕД. |
| Previous / Next (button text) | Назад / Далее |
| anchor / data pack / bundle (scene) | якорь / пакет данных / архив |
| basemap / box (Recent Imagery area) | подложка / область |
| camera move | перелёт |
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. |
| compass points (N, NNE, …) | С, ССВ, … |
| L / R (relative bearing) | Л / П |
+1
View File
@@ -35,6 +35,7 @@
"doctor": "node scripts/setup-doctor.mjs",
"format": "node scripts/format.mjs --write",
"format:check": "node scripts/format.mjs --check",
"i18n": "node scripts/i18n.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"dev": "vite",
+623
View File
@@ -0,0 +1,623 @@
// Keeps the locale catalogs in step with the code.
//
// npm run i18n check every catalog and find unmarked UI text
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
//
// A translation left as the English fails unless src/i18n/same-as-english.json
// lists it.
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
'g',
);
function unquote(literal) {
const body = literal.slice(1, -1);
return body.replace(
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs,
(_, esc) => {
if (esc[0] === 'u' || esc[0] === 'x') {
return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16));
}
return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc;
},
);
}
const ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
};
function decodeHtml(text) {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
if (name[0] === '#') {
const hex = name[1] === 'x' || name[1] === 'X';
return String.fromCodePoint(
parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10),
);
}
return ENTITIES[name.toLowerCase()] ?? all;
});
}
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
function trackedFiles(...pathspecs) {
return (
execFileSync(
'git',
[
'ls-files',
'--cached',
'--others',
'--exclude-standard',
'--',
...pathspecs,
],
{
cwd: ROOT,
encoding: 'utf8',
},
)
.split('\n')
.filter(Boolean)
// The index still lists a file moved or deleted without git mv / git rm.
.filter((file) => existsSync(path.join(ROOT, file)))
);
}
/**
* Every message the code can ask for.
* @returns {Map<string, {plural: boolean, one?: string, where: string}>}
*/
export function extractMessages() {
const messages = new Map();
const add = (text, where, plural = null) => {
if (!text) return;
const known = messages.get(text);
if (known && Boolean(known.plural) !== Boolean(plural)) {
throw new Error(
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
);
}
if (!known)
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
};
const sources = sourceFiles();
for (const file of sources) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
for (const match of source.matchAll(CALL)) {
const line = source.slice(0, match.index).split('\n').length;
const where = `${file}:${line}`;
if (match[1]) {
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
} else if (match[4]) {
if (match[4][0] === '`' && match[4].includes('${')) continue;
add(unquote(match[4]), where);
} else {
add(unquote(match[6]), where, { one: unquote(match[5]) });
}
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs))
add(normalize(decodeHtml(text)), where);
for (const [, name] of attrs.matchAll(
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
)) {
const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`));
if (value) add(decodeHtml(value[1]), where);
else
throw new Error(
`${where}: data-i18n-${name} without a ${name} attribute`,
);
}
}
}
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,
// Also showToast?.(…) and this._showToast(…).
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops
// those, so only readable text is reported. A name right after `?` is a
// ternary branch (`ok ? status : 'Fallback'`), not a property.
/(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
];
// Layer status set by assignment: this._error = …, stats.lastError = …,
// error = … (a layer factory keeps its status in `let error`). Not checked:
// the declaration itself (`const error = …`, `let status = …`) and a default
// value in a parameter list or destructuring (see defaultValue).
const ASSIGNMENT_SINK =
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
JS_SINKS.push(ASSIGNMENT_SINK);
/**
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
*/
function defaultValue(source, nameStart, valueStart) {
const before = source.slice(0, nameStart).trimEnd().at(-1);
if (!before || !'([{,'.includes(before)) return false;
const end = valueStart + expressionAt(source, valueStart).length;
const after = source.slice(end).trimStart()[0];
return Boolean(after) && ',)]}'.includes(after);
}
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;
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
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-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
* @returns {{where: string, text: string}[]}
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
found.push(
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
const seen = new Set();
return found.filter(({ where, text }) => {
const key = `${where}\0${text}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
function sourceFiles() {
return trackedFiles(
'src/*.js',
'src/*.mjs',
'src/**/*.js',
'src/**/*.mjs',
).filter(
(file) =>
!file.startsWith('src/i18n/') &&
!/\.test\.mjs$|\/testSupport\//.test(file),
);
}
const placeholders = (text) =>
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
.map((m) => m[1])
.sort()
.join(',');
export function catalogPath(locale) {
return path.join(LOCALES_DIR, `${locale}.json`);
}
export function readCatalog(locale) {
return JSON.parse(readFileSync(catalogPath(locale), 'utf8'));
}
export function shippedLocales() {
return trackedFiles('src/i18n/locales/*.json').map((file) =>
path.basename(file, '.json'),
);
}
/** 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);
/** A translation with nothing to show: t() falls back to English for it. */
const blank = (value) => value.trim() === '';
/** Words to read, beyond {placeholders}, codes and punctuation. */
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
const STILL_ENGLISH = (text, locale) =>
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
* @returns {{missing: string[], stale: string[], errors: string[]}}
*/
export function checkCatalog(
locale,
catalog,
messages,
sameAsEnglish = readSameAsEnglish(locale),
) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const missing = [];
const errors = [];
for (const [text, info] of messages) {
if (!Object.hasOwn(catalog, text)) {
missing.push(text);
continue;
}
const entry = catalog[text];
if (info.plural) {
if (!entry || typeof entry !== 'object') {
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
continue;
}
const english = new Set([info.one, englishOf(text)]);
const stillEnglish = [];
for (const category of categories) {
const form = entry[category];
if (typeof form !== 'string')
errors.push(`"${text}" lacks the "${category}" form`);
else if (blank(form))
errors.push(`"${text}" [${category}] is empty: translate it`);
else if (placeholders(form) !== placeholders(text))
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
else if (english.has(form) && hasLetters(form))
stillEnglish.push(category);
}
if (stillEnglish.length && !sameAsEnglish.has(text))
errors.push(
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
);
} else if (typeof entry !== 'string') {
errors.push(`"${text}" must be a string`);
} else if (blank(entry)) {
errors.push(`"${text}" is empty: translate it`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
hasLetters(entry) &&
!sameAsEnglish.has(text)
) {
errors.push(STILL_ENGLISH(text, locale));
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
return { missing, stale, errors };
}
/**
* What `--missing <locale>` prints: every missing entry, ready to translate.
* A template, not a translation: the check rejects a string left empty and a
* plural form left as the English.
*/
export function missingTemplate(locale, catalog, messages) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const out = {};
for (const text of checkCatalog(locale, catalog, messages, new Set())
.missing) {
const info = messages.get(text);
out[text] = info.plural
? Object.fromEntries(
categories.map((category) => [
category,
category === 'one' ? info.one : text,
]),
)
: '';
}
return out;
}
function main(args) {
const messages = extractMessages();
const missingFor = args.includes('--missing')
? args[args.indexOf('--missing') + 1]
: null;
if (missingFor) {
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
console.log(JSON.stringify(out, null, 2));
return 0;
}
let failed = false;
for (const locale of shippedLocales()) {
let catalog = readCatalog(locale);
const { missing, stale, errors } = checkCatalog(locale, catalog, messages);
if (args.includes('--prune') && stale.length) {
catalog = Object.fromEntries(
Object.entries(catalog).filter(([text]) => messages.has(text)),
);
writeFileSync(
catalogPath(locale),
`${JSON.stringify(catalog, null, 2)}\n`,
);
console.log(`${locale}: pruned ${stale.length} unused entries`);
} else if (stale.length) {
failed = true;
console.log(
`${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`,
);
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
}
if (missing.length) {
failed = true;
console.log(
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
);
for (const text of missing)
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
}
for (const error of errors) {
failed = true;
console.log(`${locale}: ${error}`);
}
if (!missing.length && !stale.length && !errors.length) {
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;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 500));
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
if (status && status !== 'CONNECTING') break;
// The label is translated; the machine state is on the control.
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
if (status && status !== 'connecting') break;
}
let diag = null;
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
* file dropped from the desktop, proved in a real browser.
*
* The dialog draws a translated row over the native file input
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
* or drops miss it. Drops a file on the row's centre via CDP and expects
* `input.files` to hold it.
*
* Usage:
* node scripts/qa-scene-dialog-file-drop.mjs
*
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
* drop selects the file, 1 otherwise.
*/
import http from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TYPES = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.json': 'application/json',
'.html': 'text/html',
};
const PAGE = `<!doctype html><html lang="en"><body>
<script type="module">
import { createSceneDialog } from '/src/ui/sceneSharing.js';
window.openDialog = () => {
const dialog = createSceneDialog('Share selected scene', () => {});
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
files.multiple = true;
window.filesInput = files;
// Centre of the visible row (the wrapper's last child).
const row = files.parentElement.lastElementChild;
const r = row.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
};
window.ready = true;
</script></body></html>`;
async function dropOn(client, x, y, file) {
const data = { items: [], files: [file], dragOperationsMask: 1 };
for (const type of ['dragEnter', 'dragOver', 'drop']) {
await client.send('Input.dispatchDragEvent', { type, x, y, data });
}
}
async function main() {
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
const pack = path.join(dir, 'pack.json');
await writeFile(pack, '{}');
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(PAGE);
return;
}
try {
const body = await readFile(path.join(ROOT, req.url));
res.writeHead(200, {
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1000, height: 800 });
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.goto(url);
await page.waitForFunction(() => window.ready === true);
const point = await page.evaluate(() => window.openDialog());
const client = await page.createCDPSession();
await dropOn(client, point.x, point.y, pack);
const names = await page.evaluate(() =>
[...window.filesInput.files].map((file) => file.name),
);
console.log(JSON.stringify({ names, errors }));
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
if (names.length !== 1 || names[0] !== 'pack.json') {
throw new Error(
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
);
}
console.log('OK: a file dropped on the visible control is selected');
} finally {
await browser.close();
server.close();
await rm(dir, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+2
View File
@@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
const GIS =
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
const HOUR = 3600_000;
// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in
// msg() and translates it when shown (likewise 'Cyclone data unavailable').
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const LAYERS = [
+6 -2
View File
@@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js';
import { decodeWindGribMessage } from './wind/decode.js';
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
// English with msg() and translates it when shown, so keep the two in sync.
const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
export function windProxy({
fetchImpl = fetch,
@@ -25,7 +29,7 @@ export function windProxy({
...(overlay === 'none' ? {} : { overlay }),
unavailable: true,
stale: true,
reason: 'Wind upstream unavailable',
reason: UPSTREAM_UNAVAILABLE,
},
});
const sendJson = (res, value, status = 200) => {
@@ -139,7 +143,7 @@ export function windProxy({
old.manifest = {
...old.manifest,
stale: true,
reason: 'Wind upstream unavailable',
reason: UPSTREAM_UNAVAILABLE,
};
return old;
}
+29 -7
View File
@@ -134,6 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
};
// The gate itself is pure and unit-tested (admitKeySetupRequest in
// src/keySetupCore.mjs) — this just feeds it the request.
// `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs.
const refusal = ({ error, code, params }) => ({ error, code, params });
const admit = (req) =>
admitKeySetupRequest({
method: req.method,
@@ -247,18 +249,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
configureServer(server) {
server.middlewares.use('/api/setup/status', (req, res) => {
if (req.method !== 'GET')
return respond(res, 405, { error: 'Method not allowed' });
return respond(res, 405, {
error: 'Method not allowed',
code: 'method',
});
const admission = admit(req);
if (!admission.ok)
return respond(res, admission.status, { error: admission.error });
return respond(res, admission.status, refusal(admission));
respond(res, 200, providerStatus());
});
server.middlewares.use('/api/setup/keys', (req, res) => {
if (req.method !== 'POST')
return respond(res, 405, { error: 'Method not allowed' });
return respond(res, 405, {
error: 'Method not allowed',
code: 'method',
});
const admission = admit(req);
if (!admission.ok)
return respond(res, admission.status, { error: admission.error });
return respond(res, admission.status, refusal(admission));
let body = '';
let overflowed = false;
req.on('data', (chunk) => {
@@ -270,15 +278,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
});
req.on('end', () => {
if (overflowed)
return respond(res, 413, { error: 'Request too large' });
return respond(res, 413, {
error: 'Request too large',
code: 'too-large',
});
let parsed;
try {
parsed = JSON.parse(body || '{}');
} catch {
return respond(res, 400, { error: 'Invalid JSON' });
return respond(res, 400, {
error: 'Invalid JSON',
code: 'invalid-json',
});
}
const verdict = validateKeySetupUpdates(parsed);
if (!verdict.ok) return respond(res, 400, { error: verdict.error });
if (!verdict.ok) return respond(res, 400, refusal(verdict));
// Neither a replace NOR a removal may touch an externally-supplied
// credential (shell env, Keychain, another workflow). This backs the
// UI's read-only "configured externally" state with a real contract —
@@ -289,6 +303,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
if (isExternallyManaged(name, inStore)) {
return respond(res, 409, {
error: `${name} is configured outside Provider Settings and can only be changed where it was set`,
code: 'external',
params: { name },
});
}
}
@@ -305,10 +321,16 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
) {
return respond(res, 500, {
error: `The key was not saved: ${error.message}`,
code:
error.code === 'GEV_HARDEN_FAILED'
? 'harden-failed'
: 'store-unreadable',
});
}
return respond(res, 500, {
error: `Could not write the ${storeName()} store`,
code: 'write-failed',
params: { store: storeName() },
});
}
// Live for the server-side proxies immediately; the restart below is
+9
View File
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'cyan',
},
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
},
],
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Palace of Fine Arts',
color: 'amber',
},
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'next to the Marina',
color: 'cyan',
},
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
{
type: 'area',
target: 'Presidio of San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'ILM / Lucasfilm',
color: 'red',
},
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
{
type: 'route',
color: 'amber',
// i18n-ignore: dev demo of voice-agent labels (not translated)
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
+16 -8
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -284,20 +286,26 @@ export function closeRing(pairs) {
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
if (!session) return t('Pick a shape, then click the map.');
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
? t('Enter to place the pin, Esc to cancel.')
: t('Click where the pin goes.');
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (need > 0)
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
? t('Those points are in a line — move one off it to enclose an area.')
: t('That line has no length — click somewhere further away.');
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
n >= MAX_VERTICES
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
: '';
return t(
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
{ measure: formatMeasure(session), full },
);
}
+31 -6
View File
@@ -36,6 +36,7 @@ import {
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
import {
DRAW_SHAPES,
MAX_VERTICES,
@@ -48,6 +49,7 @@ import {
removeLastVertex,
} from './drawMode.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -100,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
target.addEventListener(type, listener, options);
domListeners.push([target, type, listener, options]);
};
// The board lives only in memory: switching language (a reload) asks first
// while it holds a lasting mark or a shape is half drawn.
const unregisterUnsavedWork = registerUnsavedWork(() => {
if (destroyed) return null;
const lasting = (annotations.list?.() || []).some(
(mark) => mark && mark.ttlMs == null && !mark.expiring,
);
return lasting || session?.vertices?.length > 0
? msg('drawings on the map')
: null;
});
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
@@ -197,10 +210,13 @@ export function initDrawTool({ viewer, annotations }) {
// a limit rather than the harmless tail of a double-click.
if (reason === 'full')
setHint(
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
t(
'That shape already has {count} points — finish it or press Backspace.',
{ count: MAX_VERTICES },
),
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
setHint(t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
setHint(`Could not place the shape: ${error?.message || error}`);
setHint(
t('Could not place the shape: {error}', {
error: error?.message || error,
}),
);
return null;
}
};
@@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
if (!destroyed && !active) setHint(t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
@@ -324,7 +344,11 @@ export function initDrawTool({ viewer, annotations }) {
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
setHint(`${pointerOwner()} is using the pointer — close it first.`);
setHint(
t('{owner} is using the pointer — close it first.', {
owner: translateKnown(pointerOwnerName(pointerOwner())),
}),
);
return active;
}
}
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
generation += 1;
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+29 -1
View File
@@ -6,6 +6,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { initDrawTool } from './drawTool.js';
import { describeUnsavedWork } from '../i18n/index.js';
import {
isPointerFree,
pointerOwner,
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
/* ── the tests ─────────────────────────────────────────────────────────── */
// First in the file: tools left by later tests would answer the same registry.
test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => {
const h = harness();
try {
let marks = [];
h.annotations.list = () => marks;
assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing');
marks = [{ id: 'a', ttlMs: 8000, expiring: false }];
assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work');
marks = [{ id: 'b', ttlMs: null, expiring: false }];
assert.deepEqual(describeUnsavedWork(), ['drawings on the map']);
marks = [];
h.element('draw-toggle').emit('click');
h.tool.addVertex(-97.75, 30.26);
assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress');
marks = [{ id: 'c', ttlMs: null, expiring: false }];
await h.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters');
} finally {
h.restore();
}
});
test('turning Draw on takes a lease and turning it off gives it back', async () => {
const h = harness();
try {
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
h.element('draw-toggle').emit('click');
assert.equal(h.tool.active, false, 'it must not half-start');
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
assert.match(h.element('draw-hint').textContent, /directions/);
// Named by its tool, not by the internal owner id.
assert.equal(
h.element('draw-hint').textContent,
'Directions is using the pointer — close it first.',
);
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
releasePointer(other);
+11 -5
View File
@@ -4,6 +4,12 @@ import {
getActiveTrackedReadoutId,
TRACKED_OVERLAY_SOURCE_ID,
} from '../data/trackedReadout.js';
import { t } from '../i18n/index.js';
/** A label whose detailed outline could not be resolved. */
function outlineUnavailableLabel(label) {
return t('{label} · Detailed outline unavailable', { label: label || '' });
}
/**
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.poly);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.path);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
});
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
group.appendChild(parts.leader);
parts.label = makeCallout(
anno.outlineUnavailable
? `${anno.label || ''} · Detailed outline unavailable`
? outlineUnavailableLabel(anno.label)
: anno.label,
c,
);
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
}
rec.anno = anno;
if (anno.outlineUnavailable && rec.parts.label) {
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
sizeCallout(rec.parts.label);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
/** Construct the existing controls and camera presentation. */
export function createApplicationControls({
@@ -41,10 +42,10 @@ export function createApplicationControls({
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
loaderStatus.textContent = t('Flying to Austin, TX...');
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
loaderStatus.textContent = t('Restoring shared view...');
}
return { styleManager, weatherEffects, cockpitCloudEffects };
+7
View File
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
// i18n-ignore: host-panel script, no app catalogs
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
@@ -568,13 +569,16 @@ export function panelRuntime(config) {
queued = view;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
// i18n-ignore: see above
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
@@ -616,6 +620,7 @@ export function panelRuntime(config) {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
// i18n-ignore: host-panel script, no app catalogs
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
@@ -640,6 +645,7 @@ export function panelRuntime(config) {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
// i18n-ignore: product name sent to the MCP host
title: "God's Eye View",
version: '1.0.0',
},
@@ -655,6 +661,7 @@ export function panelRuntime(config) {
},
(error) =>
say(
// i18n-ignore: host-panel script, no app catalogs
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
+2 -2
View File
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
import * as sprites from '../../data/spriteOrder.js';
import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import { CITY_POIS } from '../../cityPois.js';
import * as picking from '../../data/pickRegistry.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
sprites,
activation,
overlays,
locations,
locations: { CITY_POIS },
picking,
terrain,
ground,
+11 -5
View File
@@ -14,6 +14,7 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
@@ -42,7 +43,7 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
@@ -61,8 +62,8 @@ export async function createApplicationScene({
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
? t('Loading Google 3D Tiles...')
: t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
@@ -90,12 +91,17 @@ export async function createApplicationScene({
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
loaderStatus.textContent = t(
'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
{
detail: tileErrorDetail,
},
);
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
loaderStatus.textContent = t('Initializing systems...');
const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender,
+514
View File
@@ -0,0 +1,514 @@
/**
* @module cityPois
* @description The preset cities and their landmarks — plain data with no
* Cesium, so modules that only need the names (voice cards) can import it.
*/
/**
* Points of Interest per city.
* Each city has 5 POIs; the first is the default fly-to landmark.
*
* Field reference:
* alt — RANGE (distance from target in meters), NOT absolute altitude
* heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W)
* pitch — camera tilt in degrees (negative = looking down)
* buildingHeight — estimated height of landmark center above ground (meters)
*/
export const CITY_POIS = {
austin: {
name: 'Austin',
groundElevation: 150, // meters above WGS84 ellipsoid
viewBounds: {
southwest: { lat: 30.1, lng: -97.95 },
northeast: { lat: 30.52, lng: -97.55 },
},
pois: [
{
name: 'Texas State Capitol',
lat: 30.2747,
lon: -97.7403,
alt: 550,
pitch: -28,
heading: 180,
buildingHeight: 35,
},
{
name: 'Frost Bank Tower',
lat: 30.2674,
lon: -97.7434,
alt: 550,
pitch: -22,
heading: 30,
buildingHeight: 80,
},
{
name: 'Pennybacker Bridge',
lat: 30.3451,
lon: -97.7951,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 40,
},
{
name: 'The Jenga Tower',
lat: 30.2642,
lon: -97.75,
alt: 500,
pitch: -18,
heading: 45,
buildingHeight: 60,
},
{
name: 'UT Tower',
lat: 30.2862,
lon: -97.7394,
alt: 500,
pitch: -22,
heading: 180,
buildingHeight: 50,
},
],
},
sf: {
name: 'San Francisco',
groundElevation: 15,
viewBounds: {
southwest: { lat: 37.7, lng: -122.53 },
northeast: { lat: 37.84, lng: -122.35 },
},
pois: [
{
name: 'Golden Gate Bridge',
lat: 37.8199,
lon: -122.4783,
alt: 1400,
pitch: -20,
heading: 45,
buildingHeight: 100,
},
{
name: 'Transamerica Pyramid',
lat: 37.7952,
lon: -122.4028,
alt: 500,
pitch: -25,
heading: 30,
buildingHeight: 85,
},
{
name: 'Salesforce Tower',
lat: 37.7897,
lon: -122.3972,
alt: 680,
pitch: -25,
heading: 330,
buildingHeight: 100,
},
{
name: 'Alcatraz Island',
lat: 37.8267,
lon: -122.423,
alt: 800,
pitch: -30,
heading: 0,
buildingHeight: 20,
},
{
name: 'Coit Tower',
lat: 37.8024,
lon: -122.4058,
alt: 420,
pitch: -30,
heading: 45,
buildingHeight: 30,
},
],
},
nyc: {
name: 'New York',
groundElevation: 10,
viewBounds: {
southwest: { lat: 40.477, lng: -74.259 },
northeast: { lat: 40.918, lng: -73.7 },
},
pois: [
{
name: 'Statue of Liberty',
lat: 40.6892,
lon: -74.0445,
alt: 450,
pitch: -25,
heading: 315,
buildingHeight: 45,
},
{
name: 'Empire State Building',
lat: 40.7484,
lon: -73.9857,
alt: 850,
pitch: -12,
heading: 30,
buildingHeight: 130,
buildingBounds: { height: 443, width: 130, depth: 75 },
},
{
name: 'One World Trade Center',
lat: 40.7127,
lon: -74.0134,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 170,
},
{
name: 'Brooklyn Bridge',
lat: 40.7061,
lon: -73.9969,
alt: 850,
pitch: -25,
heading: 45,
buildingHeight: 40,
},
{
name: 'Chrysler Building',
lat: 40.7516,
lon: -73.9755,
alt: 700,
pitch: -20,
heading: 225,
buildingHeight: 100,
},
],
},
tokyo: {
name: 'Tokyo',
groundElevation: 40,
viewBounds: {
southwest: { lat: 35.52, lng: 139.55 },
northeast: { lat: 35.9, lng: 139.92 },
},
pois: [
{
name: 'Tokyo Tower',
lat: 35.6586,
lon: 139.7454,
alt: 850,
pitch: -25,
heading: 0,
buildingHeight: 110,
},
{
name: 'Tokyo Skytree',
lat: 35.7101,
lon: 139.8107,
alt: 900,
pitch: -25,
heading: 30,
buildingHeight: 200,
},
{
name: 'Imperial Palace',
lat: 35.6852,
lon: 139.7528,
alt: 900,
pitch: -35,
heading: 0,
buildingHeight: 20,
},
{
name: 'Senso-ji Temple',
lat: 35.7148,
lon: 139.7967,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 25,
},
{
name: 'Mode Gakuen Cocoon Tower',
lat: 35.6929,
lon: 139.6925,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 70,
},
],
},
london: {
name: 'London',
groundElevation: 15,
viewBounds: {
southwest: { lat: 51.28, lng: -0.51 },
northeast: { lat: 51.7, lng: 0.33 },
},
pois: [
{
name: 'Tower Bridge',
lat: 51.5055,
lon: -0.0754,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 65,
},
{
name: 'The Shard',
lat: 51.5045,
lon: -0.0865,
alt: 850,
pitch: -20,
heading: 0,
buildingHeight: 100,
},
{
name: 'Big Ben / Parliament',
lat: 51.5007,
lon: -0.1246,
alt: 600,
pitch: -25,
heading: 180,
buildingHeight: 50,
},
{
name: "St. Paul's Cathedral",
lat: 51.5138,
lon: -0.0984,
alt: 400,
pitch: -30,
heading: 270,
buildingHeight: 55,
},
{
name: 'The Gherkin',
lat: 51.5145,
lon: -0.0803,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 60,
},
],
},
paris: {
name: 'Paris',
groundElevation: 35,
viewBounds: {
southwest: { lat: 48.815, lng: 2.224 },
northeast: { lat: 48.902, lng: 2.47 },
},
pois: [
{
name: 'Eiffel Tower',
lat: 48.8584,
lon: 2.2945,
alt: 750,
pitch: -25,
heading: 315,
buildingHeight: 150,
},
{
name: 'Arc de Triomphe',
lat: 48.8738,
lon: 2.295,
alt: 400,
pitch: -28,
heading: 45,
buildingHeight: 25,
},
{
name: 'Notre-Dame',
lat: 48.853,
lon: 2.3499,
alt: 400,
pitch: -25,
heading: 225,
buildingHeight: 35,
},
{
name: 'Sacré-Cœur',
lat: 48.8867,
lon: 2.3431,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 40,
},
{
name: 'Louvre Pyramid',
lat: 48.8606,
lon: 2.3376,
alt: 500,
pitch: -35,
heading: 0,
buildingHeight: 10,
},
],
},
dubai: {
name: 'Dubai',
groundElevation: 5,
viewBounds: {
southwest: { lat: 24.95, lng: 54.9 },
northeast: { lat: 25.35, lng: 55.55 },
},
pois: [
{
name: 'Burj Khalifa',
lat: 25.1972,
lon: 55.2744,
alt: 600,
pitch: -20,
heading: 200,
buildingHeight: 270,
},
{
name: 'Burj Al Arab',
lat: 25.1412,
lon: 55.1853,
alt: 500,
pitch: -25,
heading: 90,
buildingHeight: 100,
},
{
name: 'Palm Jumeirah',
lat: 25.1124,
lon: 55.139,
alt: 1200,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Dubai Frame',
lat: 25.235,
lon: 55.3003,
alt: 400,
pitch: -25,
heading: 270,
buildingHeight: 75,
},
{
name: 'Museum of the Future',
lat: 25.2197,
lon: 55.2806,
alt: 350,
pitch: -20,
heading: 30,
buildingHeight: 35,
},
],
},
dc: {
name: 'Washington DC',
groundElevation: 10,
viewBounds: {
southwest: { lat: 38.79, lng: -77.12 },
northeast: { lat: 38.995, lng: -76.91 },
},
pois: [
{
name: 'US Capitol',
lat: 38.8897,
lon: -77.0091,
alt: 550,
pitch: -25,
heading: 270,
buildingHeight: 45,
},
{
name: 'Washington Monument',
lat: 38.8895,
lon: -77.0353,
alt: 500,
pitch: -30,
heading: 0,
buildingHeight: 85,
},
{
name: 'Lincoln Memorial',
lat: 38.8893,
lon: -77.0502,
alt: 400,
pitch: -25,
heading: 90,
buildingHeight: 20,
},
{
name: 'Pentagon',
lat: 38.8711,
lon: -77.0559,
alt: 800,
pitch: -40,
heading: 0,
buildingHeight: 20,
},
{
name: 'Jefferson Memorial',
lat: 38.8814,
lon: -77.0365,
alt: 400,
pitch: -30,
heading: 0,
buildingHeight: 25,
},
],
},
tallinn: {
name: 'Tallinn',
groundElevation: 15,
viewBounds: {
southwest: { lat: 59.36, lng: 24.6 },
northeast: { lat: 59.52, lng: 24.92 },
},
pois: [
{
name: 'Viru Square',
lat: 59.4366,
lon: 24.7527,
alt: 450,
pitch: -28,
heading: 60,
buildingHeight: 25,
},
{
name: 'Old Town / Raekoja plats',
lat: 59.4372,
lon: 24.7452,
alt: 400,
pitch: -30,
heading: 180,
buildingHeight: 20,
},
{
name: 'Teatri väljak',
lat: 59.4344,
lon: 24.7514,
alt: 400,
pitch: -25,
heading: 220,
buildingHeight: 25,
},
{
name: 'Port of Tallinn',
lat: 59.4445,
lon: 24.7675,
alt: 700,
pitch: -30,
heading: 90,
buildingHeight: 20,
},
{
name: 'Ülemiste',
lat: 59.421,
lon: 24.792,
alt: 600,
pitch: -28,
heading: 45,
buildingHeight: 30,
},
],
},
};
+2 -1
View File
@@ -8,7 +8,8 @@
// say nothing about whether a destination is framed well.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CITY_POIS, LOCATIONS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { LOCATIONS } from './locations.js';
const DESTINATIONS = Object.entries(CITY_POIS);
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
+647 -183
View File
File diff suppressed because it is too large. Load diff
+45 -11
View File
@@ -83,31 +83,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/** The English emergency line; see formatCockpitEmergency. */
const ENGLISH_EMERGENCY = Object.freeze({
squawk: ({ squawk, label }) => `SQUAWK ${squawk} · ${label}`,
adsb: ({ priority, label }) =>
`ADS-B ${priority ? 'PRIORITY' : 'EMERGENCY'} · ${label}`,
});
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* @param {{label?: string, squawk?: string|null, source?: string,
* severity?: string}|null|undefined} emergency
* The cockpit passes its own formatter so the page shows it in the active
* language; this module stays i18n-free.
* @param {{label?: string, squawk?: string|null,
* source?: string, severity?: string}|null|undefined} emergency
* @param {{squawk: Function, adsb: Function}} [format] Builds the line from
* `{ name, label, squawk }` and `{ name, label, priority }`, where
* `label` is the upper-cased `name`.
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency) {
export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
const name = String(emergency.label).trim();
if (emergency.source === 'squawk' && emergency.squawk)
return `SQUAWK ${emergency.squawk} · ${label}`;
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
return `ADS-B ${field} · ${label}`;
return format.squawk({ name, label, squawk: emergency.squawk });
return format.adsb({
name,
label,
priority: emergency.severity === 'priority',
});
}
/** Format the cockpit Context scope without overstating installation coverage. */
/** The English Context scope line; see formatCockpitContextScope. */
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
installations: ({ scope, coverage }) =>
`${scope} · INSTALLATIONS ${coverage}`,
});
/**
* Format the cockpit Context scope without overstating installation coverage.
* @param {string|number} subjectLabel
* @param {number} radiusM
* @param {string|null} [installationCoverage]
* @param {{window: Function, installations: Function}} [format] Builds the
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
* `{ scope, coverage }`.
* @returns {string}
*/
export function formatCockpitContextScope(
subjectLabel,
radiusM,
installationCoverage = null,
format = ENGLISH_CONTEXT_SCOPE,
) {
const normalizedLabel =
typeof subjectLabel === 'string'
@@ -117,12 +151,12 @@ export function formatCockpitContextScope(
: '—';
const radiusKm =
Number.isFinite(radiusM) && radiusM >= 0
? String(Math.round(radiusM / 1000))
: '—';
? Math.round(radiusM / 1000)
: null;
const coverage =
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
const base = format.window({ label: normalizedLabel, radiusKm });
return coverage ? format.installations({ scope: base, coverage }) : base;
}
/** Return seven 30-degree compass divisions centered on a heading. */
+206 -53
View File
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js';
const previousDocument = globalThis.document;
afterEach(() => { globalThis.document = previousDocument; });
afterEach(() => {
globalThis.document = previousDocument;
});
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
@@ -22,23 +24,60 @@ function fixture() {
this.className = '';
this._text = '';
this.classList = {
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
add: (...names) => {
this.className = [
...new Set([
...this.className.split(/\s+/).filter(Boolean),
...names,
]),
].join(' ');
},
};
}
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
get firstElementChild() { return this.children[0] || null; }
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
set textContent(value) { this.replaceChildren(); this._text = String(value); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
get isConnected() {
return this === document.body || Boolean(this.parentNode?.isConnected);
}
get firstElementChild() {
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
);
}
get textContent() {
return (
this._text + this.children.map((node) => node.textContent).join('')
);
}
set textContent(value) {
this.replaceChildren();
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
insertBefore(node, anchor) {
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
assert.ok(
anchor === null || anchor.parentNode === this,
'insert anchor belongs to its parent',
);
if (node === anchor) return node;
node.remove();
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
const index =
anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
@@ -49,7 +88,10 @@ function fixture() {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode.children.splice(
this.parentNode.children.indexOf(this),
1,
);
this.parentNode = null;
}
replaceChildren(...nodes) {
@@ -59,20 +101,32 @@ function fixture() {
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
return (
this.tagName === 'BUTTON' &&
this.dataset.signalLayer != null &&
this.dataset.signalId != null
);
}
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
return selector.startsWith('.')
? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
for (let node = this; node; node = node.parentNode)
if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
return this.children.flatMap((child) => [
...(child.matches(selector) ? [child] : []),
...child.querySelectorAll(selector),
]);
}
focus() {
assert.ok(this.isConnected, 'a focus destination must still be connected');
assert.ok(
this.isConnected,
'a focus destination must still be connected',
);
focusCalls += 1;
document.activeElement = this;
}
@@ -85,7 +139,11 @@ function fixture() {
controller.services = { formatAwarenessLabel };
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
controller.briefTabs = [
new Node('button'),
new Node('button'),
new Node('button'),
];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
@@ -93,64 +151,131 @@ function fixture() {
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
document.body.append(
controller.signalToggle,
controller.signalList,
...controller.briefTabs,
display,
radio,
outside,
);
const selected = [];
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
const click = event => controller.handleSignalClick(event);
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
};
return { controller, document, buttons, display, radio, outside, selected, click, tab,
losses: () => focusLosses, focusCalls: () => focusCalls };
return {
controller,
document,
buttons,
display,
radio,
outside,
selected,
click,
tab,
losses: () => focusLosses,
focusCalls: () => focusCalls,
};
}
const item = (id, overrides = {}) => ({
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
key: `flight:flights:${id}`,
tone: 'nearby',
title: `Flight ${id}`,
detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id },
timestamp: 1_700_000_000_000,
...overrides,
});
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
const render = (f, items) => {
f.controller.signalItems = items;
f.controller.renderCockpitSignals();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
cohorts: [
{
id: 'flights',
count: 1,
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
},
],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
const target = f.buttons()[1]; target.focus();
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
for (let tick = 0; tick < 8; tick += 1)
f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
assert.equal(
f.focusCalls(),
calls,
'refresh must preserve focus without repeatedly restoring it',
);
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture(); render(f, [item('a')]);
const target = f.buttons()[0]; target.focus();
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
const f = fixture();
render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
detail: 'CURRENT',
timestamp: 1_700_000_001_000,
}),
]);
assert.equal(f.buttons()[0], target);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
assert.equal(
f.controller.signalList.children[0].className,
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
const target = f.buttons()[1]; target.focus();
const f = fixture();
render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
const target = f.buttons()[1];
target.focus();
const calls = f.focusCalls();
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
render(f, order.map((id) => item(id)));
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
for (const order of [
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
]) {
render(
f,
order.map((id) => item(id)),
);
assert.deepEqual(
f.buttons().map((button) => button.dataset.signalId),
order,
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
}
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const target = f.buttons()[1]; target.focus();
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
const f = fixture();
render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
f.controller.pushCockpitSignal(
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
const event = {
target: target.children[0],
prevented: false,
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const removed = f.buttons()[1]; removed.focus();
const f = fixture();
render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(f.focusCalls(), calls + 1);
f.tab(); f.tab(); f.tab();
f.tab();
f.tab();
f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
assert.equal(
f.document.activeElement,
f.display,
'later refresh must not pull focus back',
);
f.tab();
assert.equal(f.document.activeElement, f.radio);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0]; removed.focus();
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
const f = fixture();
render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(removed.isConnected, false);
+32 -1
View File
@@ -1,3 +1,5 @@
import { msg } from './i18n/marker.js';
const CONTEXT_DEPENDENCIES = Object.freeze({
flights: new Set([
'military-awareness',
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
'space-missions': new Set(['rocket-launches', 'satellites']),
});
const CONTEXT_COMPANIONS = new Set(['radio']);
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
);
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
/** Return whether an origin represents a direct user choice on this route. */
export function isExplicitUserIntentOrigin(origin, layerId = null) {
return origin === 'user' || origin === 'voice';
@@ -336,7 +344,30 @@ export function contextLayerEnableBlockReason({
return null;
}
const label = String(layerName || change.layerId || 'that layer');
return `Space Missions isolates replay data. Exit the mode to enable ${label}.`;
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
}
/**
* The layer name from a Space Missions refusal, so the UI can rebuild the
* sentence in its language.
* @param {string} reason
* @returns {{layer: string}|null} The blocked layer's name, or null when the
* reason is not a Space Missions refusal.
*/
export function parseContextLayerBlockReason(reason) {
if (
typeof reason !== 'string' ||
!reason.startsWith(BLOCK_REASON_PREFIX) ||
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
)
return null;
return {
layer: reason.slice(
BLOCK_REASON_PREFIX.length,
reason.length - BLOCK_REASON_SUFFIX.length,
),
};
}
/**
+11 -1
View File
@@ -2,6 +2,8 @@
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
import { getLocale, t } from './i18n/index.js';
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.(
'#cesium-credits .cesium-credit-expand-link',
@@ -11,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
if (!expand || !lightbox || !close) return false;
const overlay = lightbox.parentElement;
// Cesium writes these two texts in English once, when it builds the credit
// display, and never rewrites them; English keeps Cesium's own text.
if (getLocale() !== 'en') {
expand.textContent = t('Data attribution');
const title = lightbox.querySelector?.('.cesium-credit-lightbox-title');
if (title) title.textContent = t('Data provided by:');
}
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
@@ -19,7 +29,7 @@ export function configureCreditKeyboardAccess(root = document) {
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', 'Close data attribution');
close.setAttribute('aria-label', t('Close data attribution'));
if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true';
+35
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { setLocale } from './i18n/index.js';
function fakeElement() {
const attributes = new Map();
@@ -186,3 +187,37 @@ test('credit keyboard setup is idempotent and safely declines incomplete markup'
assert.equal(f.overlay.listenerCount('click'), 1);
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
});
function textFixture() {
const f = fixture();
const title = fakeElement();
title.textContent = 'Data provided by:';
f.expand.textContent = 'Data attribution';
f.lightbox.querySelector = (selector) =>
selector === '.cesium-credit-lightbox-title' ? title : f.close;
return { ...f, title };
}
test('English keeps Cesium attribution texts', () => {
setLocale('en');
const f = textFixture();
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, 'Data attribution');
assert.equal(f.title.textContent, 'Data provided by:');
});
for (const [locale, link, title] of [
['ru', 'Источники данных', 'Данные предоставлены:'],
['es', 'Atribución de datos', 'Datos proporcionados por:'],
])
test(`${locale}: Cesium attribution link and lightbox title are translated`, () => {
setLocale(locale);
try {
const f = textFixture();
configureCreditKeyboardAccess(f.root);
assert.equal(f.expand.textContent, link);
assert.equal(f.title.textContent, title);
} finally {
setLocale('en');
}
});
+13 -11
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/
import { msg } from '../i18n/marker.js';
const HELI = new Set([
'EC20',
'EC25',
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
/** Readable name of each class, for cards and readouts (English data). */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
light: msg('Light aircraft'),
glider: msg('Glider'),
turboprop: msg('Turboprop'),
airliner: msg('Airliner'),
widebody: msg('Widebody'),
quadjet: msg('Four-engine jet'),
helicopter: msg('Helicopter'),
fastjet: msg('Fast jet'),
bizjet: msg('Business jet'),
uav: msg('Drone'),
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
+11 -6
View File
@@ -13,18 +13,23 @@
* broadcast, which does not confirm an incident or its resolution.
*/
import { msg } from '../i18n/marker.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: 'General emergency', severity: 'emergency' },
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
nordo: { label: 'Radio failure', severity: 'emergency' },
downed: { label: 'Downed aircraft', severity: 'emergency' },
minfuel: { label: 'Minimum fuel', severity: 'priority' },
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
general: { label: msg('General emergency'), severity: 'emergency' },
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
nordo: { label: msg('Radio failure'), severity: 'emergency' },
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
lifeguard: {
label: msg('Lifeguard (medical priority)'),
severity: 'priority',
},
};
/**
+4
View File
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) {
const actions = terminateOwned('key-absent');
status = 'missing-key';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0;
keyFingerprint = null;
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) {
const actions = terminateOwned('no-transport');
status = 'unsupported';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'Node WebSocket transport is unavailable';
return actions;
}
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) {
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent');
scheduleRetry('transport');
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return [];
release();
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport');
return [];
+111 -23
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
// Known English from public/events/bhote-koshi-2026/event.json, translated at
// display (whole or by " · " parts). Anything else shows as authored.
const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'),
msg('Bhote Koshi valley footage'),
msg('Flood footage'),
msg('Compilation and casualty claims'),
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
msg('SOURCE GEOCONFIRMED'),
// Reconstruction caveat.
msg(
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
),
// Evidence titles that are more than a place name.
msg('Immediate collapse viewpoint'),
msg('Collapse viewpoint'),
msg('Debris-dammed lake'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Gyirong border gate'),
msg('Border gate'),
msg('Timure evidence cluster'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Dandagaun viewpoint'),
msg('Bidur - Trishuli bridge'),
msg('Bidur bridge'),
msg('Devighat / Taadi Khola Bridge'),
msg('Devighat bridge'),
]);
/**
* Display text for an authored event-pack string.
* @param {unknown} value
* @returns {string}
*/
export function bhoteEventText(value) {
const text = value == null ? '' : String(value);
if (EVENT_PACK_TEXT.has(text)) return t(text);
if (!text.includes(' · ')) return text;
return text
.split(' · ')
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
.join(' · ');
}
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
else finish(new Error(msg('Facebook player API did not initialize.')));
}
};
globalRef.fbAsyncInit = ready;
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
() => finish(new Error(msg('Facebook player API could not load.'))),
{ once: true },
);
timer =
globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
() => finish(new Error(msg('Facebook player API timed out.'))),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
else finish(new Error(msg('X embed API did not initialize.')));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('X embed could not load.')),
() => finish(new Error(msg('X embed could not load.'))),
{
once: true,
},
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
() => finish(new Error(msg('X embed timed out.'))),
EMBED_TIMEOUT_MS,
);
}).catch((error) => {
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
return _xWidgetsPromise;
}
// Outcomes a trimmed YouTube clip reports to the card status.
const YOUTUBE_PHASE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
timeout: msg('TIMEOUT'),
blocked: msg('BLOCKED'),
});
function providerLabel(provider) {
return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
t('SOURCE')
);
}
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
msg(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
),
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
current.status.textContent = t('LOADING X POST…');
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (token !== generation || record !== current) return;
if (!widget)
throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
position();
}
}
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
);
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
const leader = documentRef.createElementNS(
'http://www.w3.org/2000/svg',
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(
observation?.shortTitle || observation?.title || 'Witness source',
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
link.textContent = t('OPEN ORIGINAL ↗');
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
}
position();
},
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
},
});
}
+262 -73
View File
@@ -19,11 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
return Math.max(0, Math.min(beats.length - 1, current + delta));
}
// Evidence phases from the event pack, shown as the beat meta prefix.
const EVIDENCE_PHASE_LABELS = Object.freeze({
initiation: msg('INITIATION'),
damming: msg('DAMMING'),
'secondary failure': msg('SECONDARY FAILURE'),
'border arrival': msg('BORDER ARRIVAL'),
propagation: msg('PROPAGATION'),
'downstream passage': msg('DOWNSTREAM PASSAGE'),
consequence: msg('CONSEQUENCE'),
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
});
/** Display label for an evidence phase; unknown phases show as authored. */
function evidencePhaseLabel(phase) {
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
if (label) return t(label);
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
}
/**
* Localized name and date of an imagery endpoint for display.
* @param {{label?: string, observedAt?: string}} entry
* @returns {{name: string, date: string}}
*/
export function imageryEndpointLabel(entry) {
const label = String(entry?.label || '');
const split = label.lastIndexOf(' · ');
const authoredName = split >= 0 ? label.slice(0, split) : label;
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
let name = authoredName;
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
const match = pattern.exec(authoredName);
if (match) name = t(message, { year: match[1] });
}
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
}
function imageryEndpointText(entry) {
const { name, date } = imageryEndpointLabel(entry);
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
return count > 1
? tn(
count,
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
)
: '';
}
/** Interpolate headings across north using the shortest angular path. */
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
}
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
ctx.fillText('NO PREVIEW', 20, 30);
ctx.fillText(t('NO PREVIEW'), 20, 30);
}
function ellipsizeEvidenceText(value, limit) {
@@ -650,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
}
// The pack names the kind of source in `media.publisher` (event.json); the
// frame shows it in the UI language. Any other publisher shows as given.
const EVIDENCE_PUBLISHER_TEXT = new Set([
msg('YouTube source'),
msg('Facebook source'),
]);
/** An evidence item's publisher as its frame shows it. */
function evidencePublisherText(publisher) {
const text = String(publisher);
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
}
/** Repaint one stable frame with a poster or the current source-video frame. */
export function updateEvidenceFrame(
canvas,
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
observation.shortTitle || observation.title,
bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
infoY + 27,
);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
const publisher = String(
observation.media?.publisher || 'SOURCE LINK',
const publisher = (
observation.media?.publisher
? evidencePublisherText(observation.media.publisher)
: t('SOURCE LINK')
).toUpperCase();
ctx.fillText(
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
fitEvidenceSourceLine(
ctx,
publisher,
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
canvas.width - 32,
),
16,
infoY + 53,
);
return true;
}
// The smallest the source line's font may shrink to before the publisher is
// shortened instead.
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
/**
* Set the font for the «{publisher} / OPEN» line and return a line that fits
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
* shorten the publisher; «/ OPEN» always stays whole.
* @param {CanvasRenderingContext2D} ctx
* @param {string} publisher Display publisher, already upper-cased.
* @param {string} font CSS font with a px size.
* @param {number} maxWidth
* @returns {string}
*/
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
const line = (limit) =>
t('{publisher} / OPEN', {
publisher: ellipsizeEvidenceText(publisher, limit),
});
let limit = 24;
let text = line(limit);
ctx.font = font;
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
const width = () => Number(ctx.measureText(text)?.width) || 0;
if (width() <= maxWidth) return text;
const size = /(\d+(?:\.\d+)?)px/.exec(font);
if (size) {
const base = Number(size[1]);
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
// Width scales with the font size; step down until it fits or bottoms out.
let next = Math.max(
floor,
Math.floor(((base * maxWidth) / width()) * 10) / 10,
);
for (;;) {
ctx.font = font.replace(size[0], `${next}px`);
if (width() <= maxWidth || next <= floor) break;
next = Math.max(floor, next - 0.5);
}
}
while (width() > maxWidth && limit > 4) {
limit = Math.min(limit, publisher.length) - 1;
text = line(limit);
}
return text;
}
/** Compose source imagery and caveats into one stable renderer-owned frame. */
export function createEvidenceFrame(observation, poster = null, options = {}) {
const canvas = evidenceFrameCanvas(observation);
@@ -813,28 +958,40 @@ function collectPanelReferences(panel) {
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">▶ PLAY</button>
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
<button type="button" data-action="story-replay">↺ FULL STORY</button>
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = report.label;
status.textContent = report.status;
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.show?.({
observation: {
title: witness.title,
shortTitle: 'Rasuwagadhi witness',
shortTitle: t('Rasuwagadhi witness'),
media: { sourceUrl: witness.sourceUrl },
},
anchor,
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
collisionGroup: 'ambient-card',
zIndex: 60,
interactive: true,
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
{ source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
maxDistance: 220000,
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
t(
'A historical reference {before} percent, B post-event {after} percent',
{ before: beforePercent, after: afterPercent },
),
onChange: setSplit,
});
}
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
setElementProperty(
button,
'textContent',
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.progressInput,
'aria-label',
sceneDirected
? 'Seek Nepal scene clock'
: 'Schematic downstream progression',
? t('Seek Nepal scene clock')
: t('Schematic downstream progression'),
);
setElementProperty(
_panelRefs.timelineLabel,
'textContent',
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
);
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
setElementProperty(
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
);
const playLabel = sceneDirected
? _sceneActionPending && _sceneActionPending.type !== 'scene'
? '… PLAYING SHOT'
: '▶ PLAY SHOT'
? t('… PLAYING SHOT')
: t('▶ PLAY SHOT')
: _playing
? 'Ⅱ PAUSE'
? t('Ⅱ PAUSE')
: _progress >= 1
? _cinematicReplayArmed
? '↺ REPLAY CINEMATIC'
: '↺ REPLAY'
: '▶ PLAY';
? t('↺ REPLAY CINEMATIC')
: t('↺ REPLAY')
: t('▶ PLAY');
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
setElementProperty(
_panelRefs.playSceneButton,
'textContent',
_sceneActionPending?.type === 'scene'
? '… PLAYING SCENE'
: '▶ PLAY SCENE',
? t('… PLAYING SCENE')
: t('▶ PLAY SCENE'),
);
setElementProperty(
_panelRefs.playSceneButton,
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
setElementAttribute(
_panelRefs.playSceneButton,
'aria-label',
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
t('Continue {scene} from next shot', {
scene:
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('current scene'),
}),
);
setElementProperty(
_panelRefs.storyReplayButton,
'textContent',
'↺ FULL STORY',
t('↺ FULL STORY'),
);
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
setElementAttribute(
_panelRefs.storyReplayButton,
'aria-label',
'Replay the full reconstruction',
t('Replay the full reconstruction'),
);
}
const beatIndex = currentEvidenceIndex();
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
? _sceneContext?.shotTitle || 'SCENE SHOT'
: observation?.shortTitle || observation?.title || 'EVIDENCE',
? _sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('SCENE SHOT')
: observation?.shortTitle || observation?.title
? bhoteEventText(observation.shortTitle || observation.title)
: t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
const beatMeta = [
String(observation?.phase || 'EVIDENCE').toUpperCase(),
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
evidencePhaseLabel(observation?.phase),
t('TIME UNVERIFIED'),
sourceCount > 1
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
: '',
observation?.imageryCoverage === 'outside'
? t('OUTSIDE IMAGERY SWIPE')
: '',
]
.filter(Boolean)
.join(' / ');
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatMeta,
'textContent',
sceneDirected
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
? t('{scene} / AUTHORED SHOT', {
scene: String(
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
);
setElementProperty(
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playButton,
'aria-label',
sceneDirected
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
: 'Play reconstruction',
? t('Play {shot}', {
shot:
_sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('current scene shot'),
})
: t('Play reconstruction'),
);
setElementAttribute(
_panelRefs.previousButton,
'aria-label',
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
);
setElementAttribute(
_panelRefs.nextButton,
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
);
setElementProperty(
_panelRefs.openSourceButton,
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
count: _enabled ? 1 : 0,
status: _enabled ? 'nominal' : 'idle',
source: 'Vantor + GeoPera',
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
coverage: _enabled
? t('Rasuwa · 26 Aug 2026')
: t('Event reconstruction'),
};
},
getPlaybackState() {
+120
View File
@@ -0,0 +1,120 @@
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
// a long translation shrinks to fit the portrait card, action word whole.
// English is drawn at full size.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
// JetBrains Mono advances every glyph by 0.6 em.
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
const measure = (text, font) => text.length * 0.6 * fontPx(font);
function drawSourceLine(observation, publisher) {
const layout = evidenceCardLayout(observation);
const drawn = [];
const context = {
font: '',
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
drawImage() {},
measureText(text) {
return { width: measure(String(text), this.font) };
},
fillText(value, x) {
drawn.push({ text: String(value), font: this.font, x });
},
};
const canvas = {
width: layout.canvasWidth,
height: layout.canvasHeight,
_bhoteEvidenceLayout: layout,
getContext: () => context,
};
const subject = publisher
? { ...observation, media: { ...observation.media, publisher } }
: observation;
assert.equal(updateEvidenceFrame(canvas, subject), true);
const line = drawn.find((entry) => entry.text.includes(' / '));
assert.ok(line, 'source line drawn');
return { ...line, maxWidth: layout.canvasWidth - 32 };
}
async function portraitObservations() {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const portrait = event.evidenceSpine.filter(
(o) => evidenceCardLayout(o).kind === 'portrait',
);
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
const facebook = portrait.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube, 'a portrait YouTube card exists');
return [youtube, facebook].filter(Boolean);
}
test('English source lines draw unchanged on every card', async () => {
setLocale('en');
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
for (const observation of event.evidenceSpine) {
const line = drawSourceLine(observation);
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
assert.match(
line.text,
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
);
}
});
test('ru/es source lines fit the portrait card with the action word whole', async () => {
const observations = await portraitObservations();
try {
for (const [locale, action] of [
['ru', '/ ОТКРЫТЬ'],
['es', '/ ABRIR'],
]) {
setLocale(locale);
for (const observation of observations) {
const line = drawSourceLine(observation);
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
assert.ok(
measure(line.text, line.font) <= line.maxWidth,
`${locale}: «${line.text}» at ${line.font} overflows`,
);
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
}
}
setLocale('ru');
const ru = drawSourceLine(observations[0]);
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
} finally {
setLocale('en');
}
});
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
const [observation] = await portraitObservations();
try {
setLocale('ru');
const line = drawSourceLine(
observation,
'A very long regional broadcaster',
);
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
assert.equal(fontPx(line.font), 21 * 0.8);
} finally {
setLocale('en');
}
});
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
// The publisher on the evidence frame ("YouTube source") is pack text shown
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
import { setLocale } from '../i18n/index.js';
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
function captureFrameText(observation) {
const originalDocument = globalThis.document;
const text = [];
globalThis.document = {
createElement() {
const context = {
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
fillText(value) {
text.push(String(value));
},
drawImage() {},
};
return { width: 0, height: 0, getContext: () => context };
},
};
try {
assert.ok(createEvidenceFrame(observation));
} finally {
globalThis.document = originalDocument;
}
return text;
}
test('evidence card source line is fully in the UI language (es, ru)', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
const facebook = event.evidenceSpine.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube && facebook);
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const observation of [youtube, facebook]) {
const lines = captureFrameText(observation);
const sourceLine = lines.find((line) => line.includes(' / '));
assert.ok(sourceLine, 'source line drawn');
assert.doesNotMatch(
sourceLine,
/SOURCE/,
`${locale}: English "SOURCE" left in "${sourceLine}"`,
);
}
}
} finally {
setLocale('en');
}
});
test('evidence card source line keeps its English wording', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
setLocale('en');
const sourceLine = captureFrameText(youtube).find((line) =>
line.includes(' / '),
);
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
});
+60 -25
View File
@@ -12,6 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
const TRIGGER_SOURCE_URL =
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
// Descriptive pin titles translate at display (same entries as the event
// panel); place names stay Latin, anything else shows as authored.
const PLACE_TITLE_TEXT = new Map(
[
msg('Collapse viewpoint'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Border gate'),
msg('Dandagaun viewpoint'),
msg('Bidur bridge'),
msg('Devighat bridge'),
].map((text) => [text.toUpperCase(), text]),
);
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
}
const INCIDENT = Object.freeze({
lat: 28.3632333,
lon: 85.4423639,
heightM: 5200,
title: 'BHOTE KOSHI FLOOD INCIDENT',
subtitle: 'NEPAL · 26 AUG 2026',
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
subtitle: msg('NEPAL · 26 AUG 2026'),
});
const TRIGGER_RECORD = Object.freeze({
lat: 28.271,
lon: 85.515,
heightM: 4200,
title: 'USGS INITIAL REPORT',
title: msg('USGS INITIAL REPORT'),
details: Object.freeze([
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
msg('26 AUG 2026 · 08:37 NPT'),
msg('28.2710° N · 85.5150° E'),
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
]),
});
const TRIGGER_APPROACH_CAMERA = Object.freeze({
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
lat: 28.3949,
lon: 84.124,
heightM: 6800,
// i18n-ignore: country name, shown as is
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
Object.freeze({
id: 'kathmandu',
// i18n-ignore: city name
title: 'KATHMANDU',
lat: 27.7017,
lon: 85.3206,
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'bhaktapur',
// i18n-ignore: city name
title: 'BHAKTAPUR',
lat: 27.673,
lon: 85.43,
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'dhulikhel',
// i18n-ignore: city name
title: 'DHULIKHEL',
lat: 27.6221,
lon: 85.5428,
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: subject.title,
details: [subject.subtitle],
title: t(subject.title),
details: [t(subject.subtitle)],
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: TRIGGER_RECORD.title,
details: [...TRIGGER_RECORD.details],
title: t(TRIGGER_RECORD.title),
details: TRIGGER_RECORD.details.map((line) => t(line)),
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -549,8 +573,9 @@ export function createBhoteKoshiLocatorLayer({
protected: true,
active: true,
interactive: true,
accessibilityLabel:
accessibilityLabel: t(
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
),
activate: () => {
const opened = window.open(
TRIGGER_SOURCE_URL,
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'label',
paintLane: 'ambient-label',
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
details: [],
accent: INCIDENT_ACCENT,
sourceAlpha: progress,
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
chips: [
{
id: 'nepal-context',
// i18n-ignore: country name
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
title:
title: t(
'Show the Nepal context callout without a border or country fill',
),
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
// i18n-ignore: river name (Bhote Koshi)
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
title:
title: t(
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
),
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
label: t('CITIES'),
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
title:
title: t(
'Keep the regional incident context and introduce nearby city labels',
),
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
label: t('PLACES'),
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
title:
title: t(
'Introduce the reconstruction scene key places from trigger to downstream',
),
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
label: t('PATH'),
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
title:
title: t(
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
),
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
id: 'bhote-koshi-path-overview',
label: 'OVERVIEW',
label: t('OVERVIEW'),
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
title: 'Keep the completed flood path and location labels visible',
title: t(
'Keep the completed flood path and location labels visible',
),
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
},
{
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
label: t('RECORD'),
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
title:
title: t(
'Reveal the trigger record while making a slow camera approach',
),
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
+397 -110
View File
@@ -1,9 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import {
INCIDENT_OVERVIEW_HOLD_SEC,
INCIDENT_OVERVIEW_PLACES,
} from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { setLocale } from '../i18n/index.js';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
@@ -29,8 +33,12 @@ function viewerFixture() {
return {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
add(source) {
added.push(source);
},
remove(source) {
removed.push(source);
},
},
_test: { added, removed },
};
@@ -38,7 +46,9 @@ function viewerFixture() {
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
scheduleFrame() {
return 1;
},
cancelFrame() {},
};
}
@@ -58,24 +68,68 @@ function overlayHostFixture() {
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[88, 30],
]),
[
[80, 26],
[88, 26],
[88, 30],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[80, 26],
]),
[
[80, 26],
[88, 26],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
['bad', 27],
]),
[],
);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
const main = [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
[80, 26],
];
const island = [
[82, 25],
[83, 25],
[82, 25],
];
assert.equal(
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
main,
);
assert.equal(
largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
}),
main,
);
assert.deepEqual(
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
[],
);
});
test('Bhote Koshi locator reveals a border progressively', () => {
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0), [
positions[0],
positions[0],
]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const nepal = publication.entries.find(
({ id }) => id === 'nepal-context-callout',
);
const incident = publication.entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
});
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(
overlayHost._test.clears.at(-1),
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
);
assert.equal(layer.getStats().status, 'idle');
});
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -199,15 +275,18 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
assert.deepEqual(
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
[
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
@@ -215,21 +294,27 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
],
);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(
overlayHost._test.publications
.at(-1)
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
await layer.destroy();
});
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
scheduleFrame(callback) {
pulseFrame = callback;
return 1;
},
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const event = JSON.parse(
await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
),
);
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
assert.equal(
source.entities.values.filter(
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
).length,
1,
);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(
source.entities
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
.show.getValue(),
true,
);
assert.equal(
labels
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
.sourceAlpha(),
1,
);
assert.equal(
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
false,
);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
assert.equal(
source.entities.values.some((entity) =>
entity.id.startsWith('bhote-koshi-place-'),
),
false,
);
await layer.destroy();
});
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => 0,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const titles = new Map(
overlayHost._test.publications
.at(-1)
.entries.map(({ id, title }) => [id, title]),
);
assert.equal(
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
'01 · ВИД НА ОБРУШЕНИЕ',
);
assert.equal(
titles.get('bhote-koshi-place-gyirong-border-gate'),
'04 · ПОГРАНПЕРЕХОД',
);
assert.equal(
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
);
assert.equal(
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
'14 · МОСТ BIDUR',
);
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
await layer.destroy();
});
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
const trigger = labels.find(
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
);
const bidur = labels.find(
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
);
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
assert.ok(
path.polyline.material.color
.getValue()
.equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
),
);
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(
path.polyline.positions.getValue(),
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
});
await layer.destroy();
});
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.ok(
Cesium.Cartesian3.equals(
path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
),
);
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
source.entities.getById(
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
});
await layer.destroy();
});
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.deepEqual(
source.entities.values.map(({ id }) => id),
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
moveStart,
moveEnd,
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
cancelDelay(id) {
timers.delete(id);
},
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_clock: createPlaybackClock({
isRunning: () => false,
timingForShot: () => null,
onProgress() {},
}),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
assert.equal(
flights.length,
oldFlights.length,
'no post-Stop approach or orbit',
);
assert.equal(
layer.getStats().count,
1,
'Stop preserves the visible locator',
);
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
for (const flight of oldFlights) {
flight.cancel();
flight.complete();
}
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
assert.equal(
flights.length,
count + 1,
'successor retains its own orbit',
);
} finally {
await layer.destroy();
}
});
}
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
const incident = entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
const kathmandu = entries.find(
({ id }) => id === 'bhote-koshi-city-kathmandu',
);
const bhaktapur = entries.find(
({ id }) => id === 'bhote-koshi-city-bhaktapur',
);
const dhulikhel = entries.find(
({ id }) => id === 'bhote-koshi-city-dhulikhel',
);
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.ok(
[kathmandu, bhaktapur, dhulikhel].every(
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
),
);
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
});
await layer.destroy();
});
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
const ids = viewer._test.added[0].entities.values.map(
(entity) => entity.id,
);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('nepal-border-highlight'),
undefined,
);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
assert.deepEqual(
overlayHost._test.publications[0].entries.map(({ id }) => id),
['nepal-context-callout'],
);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
});
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
);
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
await layer.destroy();
});
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
],
});
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+6 -4
View File
@@ -8,6 +8,7 @@
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
* supplies the actual drawing surface.
*/
import { formatNumber, t } from '../i18n/index.js';
/** @constant {number} Max characters shown for a label's primary (id) line. */
const MAX_PRIMARY = 18;
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
}
/**
* Formats a speed in knots as a short rounded label. Returns '' for
* non-positive or non-finite speeds (declutters moored/unknown vessels).
* Formats a speed in knots as a short rounded label in the UI language.
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
* vessels).
* @param {number} knots - Speed over ground in knots.
* @returns {string} e.g. '14 kn', or '' when not applicable.
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
*/
export function formatKnots(knots) {
if (!Number.isFinite(knots) || knots <= 0) return '';
return Math.round(knots) + ' kn';
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
}
/**
+11
View File
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
}
});
test('formatKnots writes the unit in the UI language', async () => {
const { setLocale } = await import('../i18n/index.js');
setLocale('ru');
try {
assert.equal(formatKnots(9.5), '10 уз');
} finally {
setLocale('en');
}
assert.equal(formatKnots(9.5), '10 kn');
});
+103 -4
View File
@@ -27,6 +27,13 @@ import {
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import { instructionFor } from './routeSteps.js';
import { setLocale } from '../i18n/index.js';
import {
directionsInstruction,
routeDistanceText,
routeDurationText,
} from '../layers/directions/index.js';
import {
claimPointer,
isPointerFree,
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'21\u00a0km · 25\u00a0min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
@@ -733,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
pointerBlockedMessage('measure', 'a'),
'measure is active — turn measure off, then set A',
);
// A registered owner without an exit is named by its tool, not its id.
assert.equal(
pointerBlockedMessage('recent-imagery-box', 'a'),
'Recent imagery is active — turn Recent imagery off, then set A',
);
// Nothing to say when nothing is in the way, or when we are the holder.
assert.equal(pointerBlockedMessage(null, 'a'), null);
assert.equal(pointerBlockedMessage('', 'a'), null);
@@ -773,3 +785,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
const types = [
'depart',
'arrive',
'turn',
'new name',
'continue',
'end of road',
'fork',
'merge',
'on ramp',
'off ramp',
'roundabout',
'rotary',
'roundabout turn',
'exit roundabout',
'exit rotary',
'use lane',
'notification',
'something new',
];
const modifiers = [
null,
'left',
'right',
'slight left',
'slight right',
'sharp left',
'sharp right',
'straight',
'uturn',
];
const roads = [
{ name: '', ref: null },
{ name: 'Congress Avenue', ref: null },
{ name: 'Mannerheimintie', ref: 'E12' },
{ name: '', ref: 'I 35' },
];
for (const type of types)
for (const modifier of modifiers)
for (const road of roads)
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
const step = { type, modifier, exit, ...road };
assert.equal(
directionsInstruction(step),
instructionFor(step),
JSON.stringify(step),
);
}
// An older payload without a maneuver type keeps the proxy's sentence.
assert.equal(
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
'Head out on Kaivokatu',
);
});
test('route numbers follow the page language and never break from their unit', (t) => {
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
assert.equal(routeDurationText(Number.NaN), '');
setLocale('ru');
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
assert.equal(routeDurationText(240), '4\u00a0мин');
assert.equal(
directionsInstruction({
type: 'turn',
modifier: 'right',
name: 'East 7th Street',
}),
'Поверните направо на East 7th Street',
);
assert.equal(
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
'На кольце сверните на 2-й съезд',
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
});
+92 -34
View File
@@ -2,6 +2,7 @@
import * as Cesium from 'cesium';
import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
@@ -61,11 +62,17 @@ const KML_REMOVED_ELEMENTS = new Set([
'httpQuery',
]);
/** An error whose message is meant for the person who chose the file. */
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
*/
export class GeoImportError extends Error {
constructor(message) {
constructor(message, template = message, values = {}) {
super(message);
this.name = 'GeoImportError';
this.template = template;
this.values = values;
}
}
@@ -124,12 +131,14 @@ export function parseGeoJsonText(text) {
try {
value = JSON.parse(String(text).replace(/^/, ''));
} catch {
throw new GeoImportError('That file is not valid JSON.');
throw new GeoImportError(msg('That file is not valid JSON.'));
}
if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
throw new GeoImportError('That JSON file is not GeoJSON.');
throw new GeoImportError(msg('That JSON file is not GeoJSON.'));
if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
throw new GeoImportError('That FeatureCollection has no features list.');
throw new GeoImportError(
msg('That FeatureCollection has no features list.'),
);
return value;
}
@@ -164,7 +173,7 @@ export function countGeoJsonGeometry(
for (let i = 0; i < list.length; i++) {
if (list[i]?.type !== 'Feature')
throw new GeoImportError(
'Every GeoJSON feature must have type Feature.',
msg('Every GeoJSON feature must have type Feature.'),
);
stack.push(list[i]);
}
@@ -183,11 +192,11 @@ export function countGeoJsonGeometry(
if (node.type === 'GeometryCollection') {
const list = node.geometries;
if (!Array.isArray(list))
throw new GeoImportError('That GeoJSON has invalid geometries.');
throw new GeoImportError(msg('That GeoJSON has invalid geometries.'));
for (let i = 0; i < list.length; i++) {
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
stack.push(list[i]);
}
@@ -216,7 +225,9 @@ export function countGeoJsonGeometry(
while (coords.length) {
const [c, remaining] = coords.pop();
if (!Array.isArray(c))
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
if (remaining === 0) {
if (
c.length < 2 ||
@@ -226,7 +237,9 @@ export function countGeoJsonGeometry(
Math.abs(c[1]) > 90 ||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
)
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
positions += 1;
if (positions > maxPositions) return result('positions');
} else
@@ -234,7 +247,7 @@ export function countGeoJsonGeometry(
}
} else
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
}
return result();
@@ -245,17 +258,36 @@ function overLimitError({ over, features }) {
if (over === 'entities')
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
if (over === 'links')
return new GeoImportError(
`That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
return new GeoImportError(
`That file has more than ${limit} points (the limit) across ${features.toLocaleString()} features.`,
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
);
return new GeoImportError(
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
);
}
@@ -302,14 +334,16 @@ async function inflateRaw(bytes, maxBytes) {
total += value.length;
if (total > maxBytes)
throw new GeoImportError(
'That KMZ unpacks to more than the import limit.',
msg('That KMZ unpacks to more than the import limit.'),
);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => {});
if (error instanceof GeoImportError) throw error;
throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
throw new GeoImportError(
msg('That KMZ is damaged and could not be unpacked.'),
);
}
const out = new Uint8Array(total);
let at = 0;
@@ -332,7 +366,7 @@ export async function readZipEntries(
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fail = (message = 'That KMZ is not a readable zip archive.') => {
const fail = (message = msg('That KMZ is not a readable zip archive.')) => {
throw new GeoImportError(message);
};
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
@@ -352,7 +386,7 @@ export async function readZipEntries(
const dirSize = view.getUint32(eocd + 12, true);
let at = view.getUint32(eocd + 16, true);
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
const entries = new Map();
const names = new TextDecoder();
let inflated = 0;
@@ -370,9 +404,9 @@ export async function readZipEntries(
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
at += 46 + nameLength + extraLength + commentLength;
if (name.endsWith('/')) continue;
if (flags & 0x1) fail('That KMZ is encrypted.');
if (flags & 0x1) fail(msg('That KMZ is encrypted.'));
if (compressed === 0xffffffff || size === 0xffffffff)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
fail();
const start =
@@ -386,10 +420,13 @@ export async function readZipEntries(
if (method === 0) contents = data;
else if (method === 8)
contents = await inflateRaw(data, maxInflatedBytes - inflated);
else fail('That KMZ uses a compression method this import does not read.');
else
fail(
msg('That KMZ uses a compression method this import does not read.'),
);
inflated += contents.length;
if (inflated > maxInflatedBytes)
fail('That KMZ unpacks to more than the import limit.');
fail(msg('That KMZ unpacks to more than the import limit.'));
entries.set(name, contents);
}
return entries;
@@ -765,7 +802,7 @@ export function countKmlGeometry(
!Number.isFinite(value) ||
Math.abs(value) > limit
)
throw new GeoImportError('That KML has invalid geometry bounds.');
throw new GeoImportError(msg('That KML has invalid geometry bounds.'));
}
// Cesium creates entities for folders, a parent for every placemark, and
// a child for each geometry in a MultiGeometry, including nested containers.
@@ -822,7 +859,7 @@ function checkKmlPosition(tuple) {
Math.abs(numbers[1]) > 90 ||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
)
throw new GeoImportError('That KML has invalid coordinates.');
throw new GeoImportError(msg('That KML has invalid coordinates.'));
}
/**
@@ -843,7 +880,7 @@ export function gpxToGeoJson(
) {
const root = doc.documentElement;
if (root?.localName !== 'gpx')
throw new GeoImportError('That GPX file has no <gpx> document.');
throw new GeoImportError(msg('That GPX file has no <gpx> document.'));
const features = [];
let positions = 0;
const point = (el) => {
@@ -1028,7 +1065,11 @@ function parseXml(text, what) {
'application/xml',
);
if (doc.getElementsByTagName('parsererror').length)
throw new GeoImportError(`That ${what} file is not valid XML.`);
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
return doc;
}
@@ -1066,7 +1107,9 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
}
} catch {
throw new GeoImportError(
msg(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
),
);
}
}
@@ -1086,7 +1129,7 @@ async function loadKml(doc, viewer, signal) {
}),
new Promise((_, reject) => {
const failed = () => {
reject(new GeoImportError('That KML could not be read.'));
reject(new GeoImportError(msg('That KML could not be read.')));
};
globalThis.addEventListener?.('error', failed);
removeError = () => globalThis.removeEventListener?.('error', failed);
@@ -1116,7 +1159,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
if (!position) continue;
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic)
throw new GeoImportError('That file has invalid point coordinates.');
throw new GeoImportError(
msg('That file has invalid point coordinates.'),
);
pins.push({
entity,
original: position,
@@ -1192,7 +1237,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
if (!c)
throw new GeoImportError(
msg(
'Ground heights are not available for that file. Try again when the map has loaded.',
),
);
item.groundHeight = c.height;
c.height += item.offset;
@@ -1216,7 +1263,8 @@ async function boundedImport(promise, signal) {
abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
timer = setTimeout(
() => reject(new GeoImportError('That file took too long to read.')),
() =>
reject(new GeoImportError(msg('That file took too long to read.'))),
60_000,
);
}),
@@ -1244,16 +1292,18 @@ export async function loadGeoFile(
: AbortSignal.timeout(60_000);
signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size))
throw new GeoImportError('Nothing to import.');
throw new GeoImportError(msg('Nothing to import.'));
if (file.size > MAX_IMPORT_BYTES)
throw new GeoImportError(
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
msg('That file is larger than {size} MiB.'),
{ size: MAX_IMPORT_BYTES / 1024 / 1024 },
);
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
if (!format)
throw new GeoImportError(
'Only GeoJSON, KML, KMZ and GPX files can be imported.',
msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'),
);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
@@ -1276,7 +1326,7 @@ export async function loadGeoFile(
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
if (!size.positions)
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
prepareGeoJson(geojson);
bytes = null;
dataSource = await boundedImport(
@@ -1299,7 +1349,8 @@ export async function loadGeoFile(
if (format === 'kmz') {
entries = await readZipEntries(bytes);
main = kmzMainDocument(entries);
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
if (!main)
throw new GeoImportError(msg('That KMZ holds no KML document.'));
text = decodeXml(entries.get(main));
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
} else {
@@ -1325,6 +1376,8 @@ export async function loadGeoFile(
if (error instanceof GeoImportError) throw error;
throw new GeoImportError(
`That ${format.toUpperCase()} file could not be read.`,
msg('That {format} file could not be read.'),
{ format: format.toUpperCase() },
);
}
const entityCount = dataSource.entities.values.length;
@@ -1332,6 +1385,11 @@ export async function loadGeoFile(
revoke();
throw new GeoImportError(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
},
);
}
const now = Cesium.JulianDate.now();
@@ -1346,7 +1404,7 @@ export async function loadGeoFile(
);
if (!drawable) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
}
scrubImportedEntities(dataSource);
try {
@@ -1356,7 +1414,7 @@ export async function loadGeoFile(
throw error instanceof GeoImportError
? error
: new GeoImportError(
'Ground heights could not be read. Try that file again.',
msg('Ground heights could not be read. Try that file again.'),
);
}
// Keep the clock invariant explicit even though unused KML dates have
@@ -1366,7 +1424,7 @@ export async function loadGeoFile(
pins = createImportedPins(dataSource, viewer);
} catch {
revoke();
throw new GeoImportError('That file has invalid point graphics.');
throw new GeoImportError(msg('That file has invalid point graphics.'));
}
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
+51 -21
View File
@@ -1,25 +1,43 @@
/** Explain mapped-site availability without claiming an unobserved overload. */
export function installationFeedback(stats = {}, now = Date.now()) {
const reasons = {
rate_limited: 'Overpass rate-limited',
timeout: 'Overpass timed out',
query_failed: 'Overpass could not complete the query',
tiles_unavailable: 'Map tiles temporarily unavailable',
names_unavailable: 'Mapped names temporarily unavailable',
};
const reason =
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
import { msg } from '../i18n/marker.js';
// layers/installations/controls.js translates describeInstallationFeedback().
const REASONS = Object.freeze({
rate_limited: msg('Overpass rate-limited'),
timeout: msg('Overpass timed out'),
query_failed: msg('Overpass could not complete the query'),
tiles_unavailable: msg('Map tiles temporarily unavailable'),
names_unavailable: msg('Mapped names temporarily unavailable'),
});
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
/**
* Mapped-site availability as a message to render, without claiming an
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
* `{ count, km }` for a found-sites count (`km` is null for the view).
* @param {object} [stats]
* @param {number} [now]
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
*/
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
if (stats.loading)
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
return {
text: stats.retrying
? msg('Retrying mapped sites…')
: msg('Fetching mapped sites…'),
};
if (stats.retryAt > 0) {
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
return seconds
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
: { text: msg('{reason} — retry pending'), reason };
}
if (stats.status === 'unavailable') return reason;
if (stats.status === 'unavailable') return { text: reason };
if (stats.status === 'zoom-in')
return 'Zoom in to search mapped installations';
if (stats.stale) return 'Showing cached mapped sites';
if (stats.status === 'idle') return 'Mapped sites not loaded';
return { text: msg('Zoom in to search mapped installations') };
if (stats.stale) return { text: msg('Showing cached mapped sites') };
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
// With a count, say what was found and where; an empty area is not "loaded".
if (Number.isFinite(stats.count)) {
const km =
@@ -27,9 +45,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
Number.isFinite(stats.coverage.radiusM)
? Math.round(stats.coverage.radiusM / 1000)
: null;
const where = km ? ` within ${km} km of the contact` : ' in view';
if (stats.count === 0) return `No mapped sites${where}`;
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
return { count: stats.count, km: km || null };
}
return 'Mapped sites loaded';
return { text: msg('Mapped sites loaded') };
}
/** Explain mapped-site availability in English. */
export function installationFeedback(stats = {}, now = Date.now()) {
const feedback = describeInstallationFeedback(stats, now);
if (feedback.text !== undefined)
return feedback.text
.replace('{reason}', feedback.reason)
.replace('{seconds}', feedback.seconds);
const where = feedback.km
? ` within ${feedback.km} km of the contact`
: ' in view';
if (feedback.count === 0) return `No mapped sites${where}`;
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
}
+28
View File
@@ -0,0 +1,28 @@
import { msg } from '../i18n/marker.js';
/**
* English names for installations without a mapped name. Data keeps them
* English (server, AI tools, voice context); classLabel.js and
* militaryAwarenessEngine.js translate them.
*/
/** Name of an unnamed feature, by installation class. */
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
airfield: msg('Military airfield'),
naval_base: msg('Naval base'),
range: msg('Firing range'),
military_land: msg('Military land'),
});
/** Name of an unnamed feature without a usable class. */
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
/** Name of a military polygon the names pack has not named. */
export const UNNAMED_MILITARY_AREA = msg('Military area');
/** Every name above, for the display to translate. */
export const UNNAMED_PLACE_NAMES = Object.freeze([
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
UNNAMED_INSTALLATION_FALLBACK,
UNNAMED_MILITARY_AREA,
]);
+6 -3
View File
@@ -1,3 +1,6 @@
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
@@ -473,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedFlightsTrackingId',
expiryWindowMs: 90_000,
label: 'flight',
label: msg('flight'),
}),
military: Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedMilitaryTrackingId',
expiryWindowMs: 45_000,
label: 'military flight',
label: msg('military flight'),
}),
satellites: Object.freeze({
optionOwner: 'satellites',
optionKey: 'selectedSatTrackingId',
expiryWindowMs: 300_000,
label: 'satellite',
label: msg('satellite'),
}),
});
File diff suppressed because it is too large. Load diff
+19 -6
View File
@@ -1,5 +1,10 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
export const AWARENESS_RADIUS_M = 250000;
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
export const AWARENESS_RELATIONSHIP = Object.freeze({
@@ -47,7 +52,11 @@ export function formatAwarenessDistance(meters) {
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
/**
* Return a stable visible label without leaking missing values into UI text.
* The English name the data gives an unnamed installation ("Military area")
* is shown in the UI language; mapped names and callsigns show as they are.
*/
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [
@@ -63,7 +72,11 @@ export function formatAwarenessLabel(value) {
}
return '—';
}
if (typeof value === 'string') return value.trim() || '—';
if (typeof value === 'string') {
const text = value.trim();
if (!text) return '—';
return UNNAMED_NAMES.has(text) ? t(text) : text;
}
if (Number.isFinite(value)) return String(value);
return '—';
}
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
reason: t('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
reason: t('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
};
}
+10 -7
View File
@@ -1,3 +1,8 @@
import {
UNNAMED_INSTALLATION_FALLBACK,
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
} from './installationNames.js';
/**
* Normalize the deliberately small, allow-listed installation context returned
* by `/api/military-installations`. These are mapped features, not assertions
@@ -22,12 +27,10 @@ const CLASS_BY_MILITARY_TAG = {
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
* attribution and the details panel read it.
*/
const LABEL_BY_CLASS = {
airfield: 'Military airfield',
naval_base: 'Naval base',
range: 'Firing range',
military_land: 'Military land',
};
// The labels stay English in data (the server provider and AI tools read the
// records too); the map card and Contacts translate them (installationNames.js).
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
const MAX_FOOTPRINT_POINTS = 400;
@@ -44,7 +47,7 @@ export function humanizeInstallationClass(klass) {
const words = key.replaceAll('_', ' ').trim();
return words
? `${words[0].toUpperCase()}${words.slice(1)}`
: 'Mapped installation';
: UNNAMED_FALLBACK;
}
function finiteLatitude(value) {
+24
View File
@@ -0,0 +1,24 @@
import { msg } from '../i18n/marker.js';
/**
* Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
* screen; callers translate the English name with translateKnown().
*/
const POINTER_OWNER_NAMES = Object.freeze({
draw: msg('Draw'),
directions: msg('Directions'),
'recent-imagery-box': msg('Recent imagery'),
});
/**
* English display name of a pointer owner. An owner without a registered name
* falls back to its id.
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
* @returns {string}
*/
export function pointerOwnerName(owner) {
const id = typeof owner === 'string' ? owner.trim() : '';
if (!id) return '';
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
}
+42 -18
View File
@@ -17,54 +17,46 @@
* for), then the constellations users can actually name, then the grab-bag.
*/
import { t, msg } from '../i18n/index.js';
/**
* Class registry. `label` is the card/legend token, `color` the point color,
* `blurb` the plain-language gloss for the legend tooltip.
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
* Class registry: the point color of each class. The card and legend text is
* in CLASS_DISPLAY_KEYS.
* @type {Readonly<Record<string, { color: string }>>}
*/
export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({
label: 'STATION',
// Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in.
color: '#fff6e5',
blurb: 'Crewed stations and their visiting vehicles',
}),
nav: Object.freeze({
label: 'NAV',
// Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
}),
geo: Object.freeze({
label: 'GEO',
// Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale.
color: '#c89bff',
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
}),
visual: Object.freeze({
label: 'VISUAL',
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance.
color: '#9fb3c4',
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
}),
comms: Object.freeze({
label: 'COMMS',
// Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f',
blurb: 'Broadband constellation shell — shown only in DENSE mode',
}),
});
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
'comms',
]);
// What the card and legend show for each class.
const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({
label: msg('STATION'),
blurb: msg('Crewed stations and their visiting vehicles'),
}),
nav: Object.freeze({
label: msg('NAV'),
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
}),
geo: Object.freeze({
label: msg('GEO'),
blurb: msg(
'Geostationary belt — comms and weather, fixed over the equator',
),
}),
visual: Object.freeze({
label: msg('VISUAL'),
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
}),
comms: Object.freeze({
label: msg('COMMS'),
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
}),
});
/**
* CelesTrak group tag → { class, subtype }. Subtype names the specific
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
@@ -134,12 +152,17 @@ export function satelliteClassColor(group) {
* The ISS is always a STATION regardless of which group ingested it — see
* satelliteClassOf, which owns that rule for every consumer.
* @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
* localize: false keeps the English label (data for the AI tools).
* @returns {string} Display label.
*/
export function satelliteClassLabel(group, { isIss = false } = {}) {
export function satelliteClassLabel(
group,
{ isIss = false, localize = true } = {},
) {
const { klass, subtype } = satelliteClassOf(group, { isIss });
const base = SATELLITE_CLASSES[klass].label;
const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base;
}
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass];
if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass];
const display = CLASS_DISPLAY_KEYS[klass];
result.push({
klass,
label: spec.label,
label: t(display.label),
color: spec.color,
blurb: spec.blurb,
blurb: t(display.blurb),
count,
});
}
+94 -32
View File
@@ -19,8 +19,10 @@ import {
import satellitesLayer from './satellites.js';
import { createTrackedOverlayEntry } from './trackedReadout.js';
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
test('partial and dense catalogs do not make an early false missing decision', async () => {
_setSatelliteTrackingRefreshOutcomeForTest({
@@ -32,30 +34,44 @@ test('partial and dense catalogs do not make an early false missing decision', a
assert.match(partial.reason, /partial CelesTrak catalog/);
let releaseDense;
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
const densePromise = new Promise((resolve) => {
releaseDense = resolve;
});
_setSatelliteTrackingRefreshOutcomeForTest({
status: 'accepted',
catalog: 'dense',
densePromise,
});
let settled = false;
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
.then((result) => { settled = true; return result; });
const resolution = satellitesLayer
.resolveTrackingRestoreTarget(987654)
.then((result) => {
settled = true;
return result;
});
await Promise.resolve();
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
releaseDense({ status: 'ready' });
assert.equal((await resolution).status, 'missing');
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
_setSatelliteTrackingRefreshOutcomeForTest({
status: 'accepted',
catalog: 'core',
});
});
test('satellite first update aborts every parallel catalog request', async () => {
const realFetch = globalThis.fetch;
const observedSignals = [];
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignals.push(options.signal);
options.signal?.addEventListener('abort', () => {
options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
},
{ once: true },
);
});
try {
const controller = new AbortController();
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
assert.ok(observedSignals.length > 1);
controller.abort();
await assert.rejects(work, { name: 'AbortError' });
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
assert.equal(
observedSignals.every((signal) => signal?.aborted),
true,
);
} finally {
globalThis.fetch = realFetch;
}
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
point,
});
try {
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
assert.doesNotThrow(() =>
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
);
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
assert.equal(
_pendingSatelliteTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_applyPendingSatelliteTrackingRestoreForTest();
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
satellitesLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
assert.equal(
_pendingSatelliteTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_setSatelliteLabelLifecycleStateForTest({
viewer,
satrec: twoline2satrec(L1, L2),
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
const details = entity.gevLabelModel.details;
// Class leads the detail block; the altitude line follows it, and the
// consolidated companions stay last.
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
assert.equal(
details[0],
'STATION · ISS',
'the class line names what this is',
);
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
'companions are consolidated, named, and counted on the tracked card');
assert.equal(
details[2],
'DOCKED · PROGRESS-MS 34 · +1',
'companions are consolidated, named, and counted on the tracked card',
);
// Scoping is encoded in that string: THREE neighbours were seeded but only two
// are counted (the named one plus "+1"). The satellite 50 km away is not part
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
camera: null,
scene: { frameState: { frameNumber: 1 } },
};
const epochs = [
Date.UTC(2008, 8, 20, 12, 30),
Date.UTC(2008, 8, 20, 13, 0),
];
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
let epochIndex = 0;
_setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
_runSatellitePreRenderForTest();
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
// formatQuantity keeps the number and its unit together with a no-break space.
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
epochIndex = 1;
viewer.scene.frameState.frameNumber = 2;
_runSatellitePreRenderForTest();
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
'the class line survives an altitude-only republish');
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
assert.equal(
entity.gevLabelModel.details[0],
'STATION · ISS',
'the class line survives an altitude-only republish',
);
});
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
const ambient = ambientPublication[2][0];
assert.equal(ambient.title, 'ISS');
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
assert.equal(
ambient.position(),
point.position,
'ISS getter reads the existing point cache',
);
assert.equal(
point.label,
undefined,
'ISS point primitive carries no label graphic',
);
const trackedEntity = _trackIssForTest();
assert.ok(trackedEntity);
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
assert.equal(
viewer.entities.values.length,
1,
'runtime guard requires a real tracked entity',
);
assert.equal(trackedEntity.label, undefined);
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
assert.ok(
trackedEntity.point,
'tracked dot remains on the entity for camera framing',
);
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
assert.ok(trackedEntry);
assert.equal(trackedEntry.id, 'satellites:25544');
assert.equal(trackedEntry.protected, true);
assert.equal(trackedEntry.paintLane, 'tracked');
assert.equal(
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
calls.filter(
([type, sourceId]) =>
type === 'entries' && sourceId === 'satellites-iss',
).length,
2,
'tracking may refresh before ownership changes but must not publish a second ISS entry',
);
assert.deepEqual(calls.slice(-2), [
assert.deepEqual(
calls.slice(-2),
[
['clear', 'satellites-iss'],
['visible', 'satellites-iss', false],
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
],
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
);
_clearSatelliteLabelLifecycleForTest();
cleaned = true;
assert.equal(viewer.entities.values.length, 0);
assert.equal(
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
calls.filter(
([type, sourceId]) =>
type === 'entries' && sourceId === 'satellites-iss',
).length,
3,
'untracking republishes exactly one persistent ISS ambient entry before teardown',
);
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import {
_runSatellitePreRenderForTest,
_setTrackedSatelliteRefreshStateForTest,
_trackedFrameCartesianForTest,
} from './satellites.js';
import { setLocale } from '../i18n/index.js';
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
function run(locale) {
setLocale(locale);
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
const point = { position: new Cesium.Cartesian3() };
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
const near = (metres) => ({
get position() {
const tracked = _trackedFrameCartesianForTest();
return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z);
},
});
_setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec: twoline2satrec(L1, L2),
entity,
point,
viewer,
now: () => nowMs,
neighbours: [
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) },
],
});
_runSatellitePreRenderForTest();
nowMs += 2000;
viewer.scene.frameState.frameNumber = 2;
_runSatellitePreRenderForTest();
const details = entity.gevLabelModel.details;
setLocale('en');
return details;
}
test('es: tracked ISS card is localized', () => {
const details = run('es');
assert.equal(details[0], 'ESTACIÓN · ISS');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
test('ru: tracked ISS card is localized', () => {
const details = run('ru');
assert.equal(details[0], 'СТАНЦИЯ · ISS');
assert.match(details[1], /км/, 'ru glossary: km → км');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
+41 -1
View File
@@ -4,6 +4,8 @@
* after the vessel cards moved out of their dedicated canvas renderer.
*/
import { t, msg } from '../i18n/index.js';
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */
export const VESSEL_LABEL_GRID_PX = 118;
@@ -13,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
/**
* AIS type family → chevron hue + card accent. Single source of truth for
@@ -64,6 +68,42 @@ const NUMERIC_TYPE_FAMILIES = {
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: msg('FISHING'),
TOWING: msg('TOWING'),
DREDGER: msg('DREDGER'),
'DIVE OPS': msg('DIVE OPS'),
MILITARY: msg('MILITARY'),
SAILING: msg('SAILING'),
PLEASURE: msg('PLEASURE'),
PILOT: msg('PILOT'),
SAR: msg('SAR'),
TUG: msg('TUG'),
'PORT TENDER': msg('PORT TENDER'),
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
'LAW ENFORCE': msg('LAW ENFORCE'),
MEDICAL: msg('MEDICAL'),
'HIGH-SPEED': msg('HIGH-SPEED'),
PASSENGER: msg('PASSENGER'),
CARGO: msg('CARGO'),
TANKER: msg('TANKER'),
OTHER: msg('OTHER'),
});
/**
* Card-display form of a normalized AIS type: known tokens translate through
* the catalog; free-text types and unknown codes render unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function displayVesselType(type) {
const normalized = normalizeVesselType(type);
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
return key ? t(key) : normalized;
}
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
@@ -148,7 +188,7 @@ export function applyVesselOverlayPolicy(
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
+96
View File
@@ -0,0 +1,96 @@
// A translated vessel type is never cut mid-word on the card.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js';
import { displayVesselType } from './vesselLabels.js';
import { setLocale } from '../i18n/index.js';
const POS = new Cesium.Cartesian3(1, 2, 3);
const rec = (type) => ({
name: 'X',
mmsi: '1',
type,
destination: '',
speed: 5,
course: 10,
heading: 10,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
// Every AIS ShipType code with its own display token.
const CODES = [
'30',
'31',
'33',
'34',
'35',
'36',
'37',
'50',
'51',
'52',
'53',
'54',
'55',
'58',
'40',
'60',
'70',
'80',
'90',
];
// The card label is acceptable when it is the whole translation, or when it
// was shortened at a word boundary and marked with an ellipsis.
function assertNotCutMidWord(label, full, where) {
if (label === full) return;
const head = label.replace(/(…|\.\.\.)$/, '');
assert.notEqual(
head,
label,
`${where}: "${label}" cut from "${full}" without an ellipsis`,
);
assert.ok(
full.startsWith(head),
`${where}: "${label}" is not a prefix of "${full}"`,
);
assert.match(
full.slice(head.length),
/^[\s-]/,
`${where}: "${label}" cuts "${full}" mid-word`,
);
}
for (const locale of ['es', 'ru']) {
test(`${locale}: vessel type on cards is never cut mid-word`, () => {
setLocale(locale);
try {
for (const code of CODES) {
const full = displayVesselType(code).toUpperCase();
const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0];
const selected = buildSelectedVesselCard(rec(code)).details[0].split(
' · ',
)[0];
assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`);
assertNotCutMidWord(selected, full, `${locale} ${code} selected`);
}
} finally {
setLocale('en');
}
});
}
test('en: free feed text is still bounded to the card width', () => {
setLocale('en');
assert.equal(
buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0],
'CRUDE OIL TANK',
);
assert.equal(
buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0],
'ANTI-POLLUTION',
);
});
+1
View File
@@ -15,6 +15,7 @@ export class SceneDocumentError extends Error {
super(`${path}: ${reason}`);
this.name = 'SceneDocumentError';
this.path = path;
this.reason = reason;
}
}
export const fail = (path, reason) => {
+7 -4
View File
@@ -1,5 +1,8 @@
import { msg } from '../../i18n/marker.js';
import { BUNDLE_SOURCE } from './bundle.js';
// Statuses stay English; scenes/sharing.js translates them for the dialog.
/** Describe an import without fetching assets, applying state or exposing source configuration. */
export function describeSceneShare(
{ project, assets },
@@ -16,11 +19,11 @@ export function describeSceneShare(
status:
pack.source.adapter === BUNDLE_SOURCE
? assets.has(pack.source.path)
? 'Included in bundle'
: 'Missing bundle file — reimport its bundle'
? msg('Included in bundle')
: msg('Missing bundle file — reimport its bundle')
: sources.has(pack.source.adapter)
? 'Source configured; file checked when loaded'
: 'Source unavailable',
? msg('Source configured; file checked when loaded')
: msg('Source unavailable'),
})),
);
return {
+36 -13
View File
@@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
// Choosing a mission is deliberately NOT durable suppression: picking a mission
// is enthusiasm, not "never show me this again".
import { t, msg } from './i18n/index.js';
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
@@ -34,10 +36,10 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1';
*/
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
const ENVIRONMENTAL_LABELS = Object.freeze({
ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }),
EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }),
ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }),
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: msg('EARTH WATCH'),
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
});
/**
@@ -45,7 +47,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({
* @returns {{title: string}} The label set the constant above selects.
*/
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;
const key =
ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;
return { title: t(key) };
}
/*
@@ -92,12 +96,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
contacts: Object.freeze({
kind: 'context',
contextMode: 'contacts',
busyText: 'Starting live contacts…',
busyKey: msg('Starting live contacts…'),
}),
'space-missions': Object.freeze({
kind: 'context',
contextMode: 'space-missions',
busyText: 'Opening space missions…',
busyKey: msg('Opening space missions…'),
}),
environmental: Object.freeze({
kind: 'globe',
@@ -115,7 +119,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
// are judged on the layer row, which tells them the truth.
layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyText: 'Scanning active events…',
busyKey: msg('Scanning active events…'),
}),
explore: Object.freeze({ kind: 'none' }),
});
@@ -355,12 +359,25 @@ export function initFirstRunExperience({
}
// The tile name is owner-switchable from one constant, so paint it from the
// module rather than trusting the markup to have been edited to match.
// module rather than trusting the markup to match. The description and the
// tile subcopy are data-i18n-runtime in welcome.html and translated here.
const environmentalTitle = root.querySelector(
'[data-first-run-environmental-title]',
);
if (environmentalTitle)
environmentalTitle.textContent = environmentalLabel().title;
const environmentalSub = root.querySelector(
'[data-first-run-environmental-title] + small',
);
if (environmentalSub)
environmentalSub.textContent = t(
'Live earthquakes and active fires, from USGS and NASA',
);
const description = root.querySelector('#first-run-description');
if (description)
description.textContent = t(
'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
);
const status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]');
@@ -443,7 +460,9 @@ export function initFirstRunExperience({
button.setAttribute('aria-disabled', String(next));
if (!status) return;
if (next)
status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…';
status.textContent = t(
FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'),
);
else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus;
};
@@ -493,7 +512,10 @@ export function initFirstRunExperience({
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) {
status.dataset.sticky = 'true';
status.textContent = `Could not open that mission${detail}. Retry or explore manually.`;
status.textContent = t(
'Could not open that mission{detail}. Retry or explore manually.',
{ detail },
);
}
setBusy(false);
};
@@ -509,8 +531,9 @@ export function initFirstRunExperience({
if (box) box.checked = !wanted;
if (!status) return;
status.dataset.sticky = 'true';
status.textContent =
'This browser is blocking storage, so that could not be saved.';
status.textContent = t(
'This browser is blocking storage, so that could not be saved.',
);
};
const keyboard = createSurfaceKeyboard({
+471 -132
View File
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
rememberFirstRunSessionDismissed(session);
assert.equal(session.read(), 'dismissed');
// Gone for THIS session...
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: session,
location: { search: '' },
}),
false,
);
// ...and back in the next one, because sessionStorage did not survive it.
assert.equal(shouldShowFirstRun({
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
}),
true,
);
// Session dismissal must never have written the durable key.
assert.equal(storage.read(), null);
});
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
setFirstRunSuppressed(true, storage);
assert.equal(storage.read(), 'suppressed');
assert.equal(shouldShowFirstRun({
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), false);
}),
false,
);
// Unticking before dismissing takes the suppression back.
setFirstRunSuppressed(false, storage);
assert.equal(storage.read(), null);
assert.equal(shouldShowFirstRun({
assert.equal(
shouldShowFirstRun({
storage,
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
}),
true,
);
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
setFirstRunSuppressed(true, storage);
assert.equal(shouldShowFirstRun({
assert.equal(
shouldShowFirstRun({
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
location: { search: '' },
}), true);
}),
true,
);
});
test('welcome params work in both directions and outrank both suppressions', () => {
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
// ?welcome=1 replays past the checkbox AND past a session dismissal.
assert.equal(shouldShowFirstRun({
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
}), true);
assert.equal(
shouldShowFirstRun({
storage: suppressed,
sessionStorageRef: dismissed,
location: { search: '?welcome=1' },
}),
true,
);
// ?welcome=0 suppresses a session that would otherwise see it.
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
assert.equal(
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
false,
);
// A share link outranks everything, including the replay hatch.
assert.equal(shouldShowFirstRun({
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
}), false);
assert.equal(
shouldShowFirstRun({
hasShareState: true,
...fresh(),
location: { search: '?welcome=1' },
}),
false,
);
});
test('a share link never sees the launcher — its author already chose the view', () => {
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
test('privacy-restricted storage fails open and every write stays best-effort', () => {
const blocked = {
getItem: () => { throw new Error('blocked'); },
setItem: () => { throw new Error('blocked'); },
removeItem: () => { throw new Error('blocked'); },
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
removeItem: () => {
throw new Error('blocked');
},
};
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
assert.equal(
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
true,
);
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
});
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
// before the function body starts, so the SecurityError escapes every
// try/catch in the module and the launcher silently never appears.
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
const savedSession = Object.getOwnPropertyDescriptor(
globalThis,
'sessionStorage',
);
const hostile = {
configurable: true,
get() { throw new Error('SecurityError: The operation is insecure.'); },
get() {
throw new Error('SecurityError: The operation is insecure.');
},
};
Object.defineProperty(globalThis, 'localStorage', hostile);
Object.defineProperty(globalThis, 'sessionStorage', hostile);
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
} finally {
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
else delete globalThis.localStorage;
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
if (savedSession)
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
else delete globalThis.sessionStorage;
}
});
test('no storage is touched from a default parameter position', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// Comments stripped first: the block explaining this very defect quotes the
// bad pattern, and matching prose instead of code would make the pin a liar.
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const code = module
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(
code,
/=\s*globalThis\.(local|session)Storage/,
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
);
// The only reads of the global areas live inside the guarded resolver.
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
assert.equal(
globalReads.length,
2,
'exactly two global storage reads, both in resolveStore',
);
const resolver = code.slice(
code.indexOf('function resolveStore'),
code.indexOf('function readStored'),
);
assert.equal(
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
2,
'both global storage reads must be inside resolveStore, inside its try',
);
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
assert.match(
resolver,
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
);
assert.match(code, /function readStored\(kind, injected, key\)/);
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
});
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.deepEqual(
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
);
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
'cockpit-mode',
'recording-mode',
'scene-playback-mode',
'ui-clean-view',
]);
// A surface that hides the card in CSS but is missing from the JS list would
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
const hideRule = css.slice(
css.indexOf('body.ui-clean-view #first-run-launcher'),
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
css.indexOf(
'display: none',
css.indexOf('body.ui-clean-view #first-run-launcher'),
),
);
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
const inCss = [
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
].map((m) => m[1]);
assert.deepEqual(
inCss.sort(),
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
'every screen-claiming surface must appear in BOTH lists',
);
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
assert.match(
module,
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
);
});
test('exclusiveSurfaceActive reads the live body classes', () => {
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
const make = (classes) => ({
body: { classList: { contains: (name) => classes.includes(name) } },
});
assert.equal(exclusiveSurfaceActive(make([])), false);
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
assert.equal(
exclusiveSurfaceActive(make([name])),
true,
`${name} must count as exclusive`,
);
}
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
assert.equal(exclusiveSurfaceActive(undefined), false);
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
});
test('the key handler refuses to act for a card that is not really on screen', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
// Real visibility, not just the class: the class survives while CSS hides the
// card, which is precisely how a Scene left an invisible ESC handler armed.
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
assert.match(
module,
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
);
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
const handler = keyboard.slice(
keyboard.indexOf('const onKeyDown = (event) => {'),
);
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/!isActive\(\)/,
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
});
test('an overlay with NO class to watch still disarms the launcher', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// The repro, pinned as the stacking it actually is: the attribution lightbox
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
// buried launcher and wrote the session flag while the lightbox stayed open.
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
assert.match(
overlay.slice(0, overlay.indexOf('}')),
/z-index: 200 !important/,
);
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
// launcher acting under an overlay can never become why ESC stopped working.
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
assert.match(
covered,
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
);
assert.match(
covered,
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
);
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
});
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(
new URL('./ui/radioBindings.js', import.meta.url),
'utf8',
);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// stopPropagation() does NOT stop later listeners on the SAME document, so the
// disclosure's earlier capture handler closed the disclosure and the launcher
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
assert.match(claim, /event\.preventDefault\(\);/);
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself');
assert.doesNotMatch(
claim,
/event\.stopPropagation\(\);/,
'the plain form leaves the launcher listening and is the defect itself',
);
// Belt on the launcher side: a key another surface already marked is not ours,
// whether or not that surface remembered to silence us.
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
const handler = keyboard.slice(
keyboard.indexOf('const onKeyDown = (event) => {'),
);
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/event\.defaultPrevented\) return;/,
'a marked key must be somebody else\'s key',
"a marked key must be somebody else's key",
);
});
test('a refused write takes the tick back instead of promising "never again"', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The write stays best-effort; the OUTCOME is now reported, because a box left
// ticked after a refused write tells the visitor the launcher is gone for good
// while it is already guaranteed to return next session.
const blocked = {
getItem: () => null,
setItem: () => { throw new Error('blocked'); },
removeItem: () => { throw new Error('blocked'); },
setItem: () => {
throw new Error('blocked');
},
removeItem: () => {
throw new Error('blocked');
},
};
assert.equal(setFirstRunSuppressed(true, blocked), false);
assert.equal(setFirstRunSuppressed(false, blocked), false);
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
module.indexOf('const onSuppressChange = (event) => {'),
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
);
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
assert.match(
handler,
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
);
assert.match(
handler,
/box\.checked = !wanted;/,
'a refused write must revert the tick',
);
});
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const state = fs.readFileSync(
new URL('../docs/CURRENT-STATE.md', import.meta.url),
'utf8',
);
// A "reveal anyway after N seconds" would trade a benign no-show for the card
// punching through a recording in progress — recordings run long, and none of
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
module.indexOf(' const onViewportResize'),
);
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
'the pin must cover the function the note governs, not just the note');
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
'the acceptance is the decision NOT to time this out');
assert.ok(
accepted.length > 0,
'the acceptance must be written where the next editor will read it',
);
assert.match(
accepted,
/const syncToExclusiveSurfaces = \(\) => \{/,
'the pin must cover the function the note governs, not just the note',
);
assert.doesNotMatch(
accepted,
/setTimeout|setInterval/,
'the acceptance is the decision NOT to time this out',
);
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
assert.match(state, /a surface class that never clears means no launcher for that page/i);
assert.match(
state,
/a surface class that never clears means no launcher for that page/i,
);
});
test('the scroll fade only appears when the list really overflows', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
assert.match(
module,
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
);
assert.match(
module,
/choiceList\.dataset\.scrollable = String\(overflows\);/,
);
// ...and the CSS must be gated on that flag, never on the media query alone.
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
assert.match(
css,
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
);
const bareFade =
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
assert.doesNotMatch(
bareFade,
/mask-image/,
'the fade must never apply unconditionally',
);
// Rotating a phone changes which tiles fit, so it re-measures.
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
});
test('the launcher yields on engage and waits when a surface is already up', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// Both directions from one observer: yield if it is up and something takes
// the screen; wait to reveal if something already has it.
assert.match(
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
// not see a new hold because of onboarding chrome.
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
assert.doesNotMatch(
module,
/setInterval|requestAnimationFrame\(function poll/,
);
});
// ── Per-mission behavior ─────────────────────────────────────────────────────
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
function missionSpy({
contextOk = true,
layerResult = () => true,
globe = async () => ({ ok: true }),
} = {}) {
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
return {
calls,
deps: {
setContextMode: async (mode) => {
calls.contextModes.push(mode);
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
return contextOk
? { ok: true, mode }
: { ok: false, failedLayerIds: ['rocket-launches'] };
},
setLayerEnabled: async (layerId) => {
calls.layerIds.push(layerId);
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
// went is the one-click globe-scale dump. Restoring the tile needs the
// globe-LOD declutter first.
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
'contacts', 'space-missions', 'environmental', 'explore',
'contacts',
'space-missions',
'environmental',
'explore',
]);
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
'the infrastructure mission must be gone, not dormant');
assert.equal(
FIRST_RUN_MISSIONS.infrastructure,
undefined,
'the infrastructure mission must be gone, not dormant',
);
});
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
for (const [choice, mode] of [
['contacts', 'contacts'],
['space-missions', 'space-missions'],
]) {
const spy = missionSpy();
const outcome = await runFirstRunChoice(choice, spy.deps);
assert.equal(outcome.ok, true);
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
// post-launch, and the note must stay where the next editor will read it
// rather than being re-discovered as a launcher bug.
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const table = module.slice(
module.indexOf(' environmental: Object.freeze({'),
module.indexOf(' explore:'),
);
assert.match(table, /KEY REQUIRED/);
assert.match(table, /src\/loadingFeedback\.js/);
assert.match(table, /LEDGERED post-launch/);
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
assert.equal(outcome.ok, true);
assert.deepEqual(outcome.failedLayerIds, []);
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(
module.slice(module.indexOf('export async function runFirstRunChoice')),
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
// The globe flight is framing. A cancelled or throwing flight is not a failure.
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
const flightDown = missionSpy({
globe: () => {
throw new Error('cancelled');
},
});
assert.equal(
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
true,
);
});
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
const spy = missionSpy();
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
assert.deepEqual(spy.calls, {
contextModes: [],
layerIds: [],
globeFlights: 0,
});
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
assert.deepEqual(spy.calls, {
contextModes: [],
layerIds: [],
globeFlights: 0,
});
});
test('a failed Context mission reports the layers the facade named', async () => {
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
assert.equal(
environmentalLabel().title,
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
);
});
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
test('no mission writes a preference the visitor did not choose by picking it', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
const code = module.slice(
module.indexOf('export function shouldShowFirstRun'),
);
// Layer enables ARE durable in this app and a mission tile IS that choice, so
// they run at the same origin a click on those rows uses.
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
'_setModels3dParams',
'setFeather',
]) {
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
assert.doesNotMatch(
code,
new RegExp(forbidden),
`a mission must never touch ${forbidden}`,
);
}
// The only durable panel write is the Context reveal, and only on the Context
// missions — the globe missions open no panel at all.
const panelWrites = code.match(/setPanelCollapsed/g) || [];
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
assert.equal(
panelWrites.length,
1,
'exactly one panel reveal, on the Context path',
);
const contextPath = code.slice(
code.indexOf('setContextMode: async (mode)'),
code.indexOf('setLayerEnabled:'),
);
assert.match(
contextPath,
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
);
});
test('the decision table is written down where the next editor will read it', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
assert.match(
module,
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
);
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
assert.ok(
module.includes(row),
`decision table is missing its "${row}" rows`,
);
}
assert.match(module, /SHOW POLICY/);
});
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
// ── Markup, startup ordering, accessibility ─────────────────────────────────
test('markup, startup ordering and accessibility remain pinned', () => {
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
const html = expandApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const startup = fs.readFileSync(
new URL('./app/startupChrome.js', import.meta.url),
'utf8',
);
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
assert.match(
html,
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
);
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
assert.match(
html,
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
);
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
assert.match(html, /<strong data-first-run-environmental-title>/);
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
// half of what it does is the defect this replaced. Only the VISIBLE <small>
// text counts; the comment beside it naturally says the words too.
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
const envTile = html.slice(
html.indexOf('data-first-run-choice="environmental"'),
);
const visible = envTile.slice(
envTile.indexOf('<small>'),
envTile.indexOf('</small>'),
);
assert.match(visible, /earthquakes/i);
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
assert.match(
visible,
/fires?/i,
'the tile must promise the fires it enables',
);
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
// unspaced em dash included. This is copy, not prose to be improved in a
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
+ '—then you realize the sources are public and the data is real.</p>'),
html.includes(
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
'—then you realize the sources are public and the data is real.</p>',
),
'the owner-authored first-run line must ship exactly as written',
);
// Menu order is the owner's, read straight off the markup.
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind');
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
(match) => match[1],
);
assert.deepEqual(order, [
'contacts',
'space-missions',
'environmental',
'explore',
]);
assert.doesNotMatch(
html,
/data-first-run-choice="infrastructure"/,
'the removed tile must leave no markup behind',
);
assert.match(startup, /styleManager\.initialRestorePromise/);
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
assert.ok(
startup.indexOf("loadingScreen.classList.add('hidden')") <
startup.indexOf(
"loadingScreen.addEventListener('transitionend', revealFirstRun",
),
);
assert.match(
startup,
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
);
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
assert.match(css, /body\.recording-mode #first-run-launcher/);
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
// matching one of THOSE would let the launcher's own opt-out be deleted.
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
.map((match) => match[1]);
const reducedMotion = [
...css.matchAll(
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
),
].map((match) => match[1]);
assert.ok(
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
reducedMotion.some(
(block) =>
block.includes('#first-run-launcher') &&
block.includes('.first-run-choices'),
),
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
);
// The card must be click-through until revealed and again while it fades out.
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
const base = css.slice(
css.indexOf('#first-run-launcher {'),
css.indexOf('#first-run-launcher.visible'),
);
assert.match(base, /pointer-events: none/);
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
assert.match(
css,
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
);
// The card is a flex column so its mission list can scroll on a short
// viewport — and an AUTHOR `display` on this id outranks the UA's
// `[hidden] { display: none }`, which would strand the card in the
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
});
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// aria-disabled, never the `disabled` property: disabling a focused button
// drops the keyboard to <body> and strands the visitor outside the launcher.
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
assert.match(
module,
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
);
assert.doesNotMatch(module, /button\.disabled = /);
// Tab is confined to the launcher, and ESC always releases it.
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const keyboard = fs.readFileSync(
new URL('./ui/surfaceKeyboard.js', import.meta.url),
'utf8',
);
assert.match(module, /keyboard\.activate\(\)/);
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
assert.match(keyboard, /event\.key !== 'Tab'/);
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
});
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
const ui = fs.readFileSync(
new URL('./ui/panelPositionControls.js', import.meta.url),
'utf8',
);
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
// now, so it was opening to offer controls for things already happening —
// while competing with the mission card for the one first impression there is.
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
);
// The first-run default may only apply when NOTHING is stored: a visitor who
// opened the rail keeps it open, and one who closed it keeps it closed.
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
const block = ui.slice(
ui.indexOf(
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
),
);
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
(tool) => tool.name !== 'set_cyber_sonar',
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
.parameters.properties.layout;
assert.deepEqual(hudLayout.enum, [
'tactical',
'operator',
'minimal',
'cyber',
]);
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
const block = JSON.stringify(legacyTools);
@@ -681,7 +1001,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
const instructions = fs.readFileSync(
new URL('../server/providers/openai/instructions.js', import.meta.url),
'utf8',
);
// ...and the mapping that makes them reachable by voice is one instruction
// string, whose rollback is deleting that string. Anchored to a LIVE array
@@ -692,23 +1015,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
/\n\s+'NAMED VIEWS are shorthand/,
'the mission mapping must be an active instruction entry, not commented out',
);
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
const mapping = instructions.slice(
instructions.indexOf('NAMED VIEWS are shorthand'),
);
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
for (const layerId of [
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'earthquakes',
]) {
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
assert.ok(
paragraph.includes(layerId),
`mapping must name the existing ${layerId} enum value`,
);
}
assert.ok(paragraph.includes('zoom_to_globe'));
assert.ok(paragraph.includes('set_layer_visibility'));
});
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
const tool = GEV_REALTIME_TOOLS.find(
(tool) => tool.name === 'set_layer_visibility',
);
const allowedLayers = tool.parameters.properties.layerId.enum;
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
(mission) => mission.layerIds || [],
);
assert.ok(missionLayerIds.length > 0);
for (const layerId of missionLayerIds) {
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
assert.ok(
allowedLayers.includes(layerId),
`${layerId} must already be an allowed enum value`,
);
}
});
+125 -19
View File
@@ -16,8 +16,9 @@ import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js';
import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
@@ -27,7 +28,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
@@ -35,6 +36,8 @@ import {
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -60,6 +63,79 @@ const HUD_COLORS = {
},
};
/**
* Mode names per style: the same English as STYLE_STATUS_LABELS
* (ui/visualPresets.js), so both 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' });
}
}
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
@@ -185,6 +261,7 @@ export class IntelHUD {
this._el = document.getElementById('intel-hud');
if (!this._el) return;
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
@@ -199,17 +276,17 @@ export class IntelHUD {
<div class="hud-content">
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">SUMMARY</div>
<div class="hud-summary" id="hud-summary">Awaiting telemetry...</div>
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> REC <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
</div>
<div class="hud-bracket">┐</div>
@@ -227,7 +304,7 @@ export class IntelHUD {
<div class="hud-content" style="text-align:right">
<div id="hud-gsd">GSD: --m NIIRS: --</div>
<div id="hud-alt">ALT: --m SUN: --° EL</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
</div>
<div class="hud-bracket">┘</div>
</div>
@@ -355,9 +432,11 @@ export class IntelHUD {
const formatted = this._formatMGRS(mgrsStr);
mgrsLabel = formatted;
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = `MGRS: ${formatted}`;
} catch {
const el = document.getElementById('hud-mgrs');
// i18n-ignore: sensor codes
if (el) el.textContent = 'MGRS: ---';
}
@@ -366,6 +445,7 @@ export class IntelHUD {
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
const bottomEl = document.getElementById('hud-bottom-line');
if (bottomEl) {
// i18n-ignore: sensor codes
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
}
@@ -381,6 +461,7 @@ export class IntelHUD {
);
const gsdEl = document.getElementById('hud-gsd');
if (gsdEl)
// i18n-ignore: sensor codes
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
@@ -393,6 +474,7 @@ export class IntelHUD {
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
if (altEl)
// i18n-ignore: sensor codes
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
@@ -402,6 +484,7 @@ export class IntelHUD {
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
// i18n-ignore: sensor codes
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
}
@@ -410,6 +493,7 @@ export class IntelHUD {
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona');
// i18n-ignore: sensor codes
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
// `altM` stays the raw ellipsoidal camera height the sensor model reads
@@ -634,10 +718,10 @@ export class IntelHUD {
*/
_composeSummary() {
const m = this._latestMetrics;
if (!m) return 'Awaiting telemetry...';
if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode');
const modeLabel = modeEl?.textContent || 'NORMAL';
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
const region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
@@ -649,20 +733,41 @@ export class IntelHUD {
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
// Display only: the AI summary request carries its own context, so this
// line is built in the active language.
const altTag =
altDisplayM >= 1000
? `${(altDisplayM / 1000).toFixed(1)}KM`
: `${Math.round(altDisplayM)}M`;
? hudQuantity(altDisplayM / 1000, 'km', 1)
: hudQuantity(Math.round(altDisplayM), 'm');
const winTag = window
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
: 'N/A';
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
: t('N/A');
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
const localityTag = composeLocalityTag(
nearest,
m.latDeg,
m.lonDeg,
LOCALIZED_LOCALITY,
);
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
const provenance = localizedProvenanceTag(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
);
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t(
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
{
mode: modeLabel,
band: viewBandLabel(band),
locality: localityTag,
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
alt: altTag,
window: winTag,
sun: formatNumber(Math.round(m.sunEl)),
ona: formatNumber(Math.round(m.ona)),
zone: localTag,
},
);
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
return provenance ? `${line} | ${provenance}` : line;
}
@@ -818,8 +923,9 @@ export class IntelHUD {
// Update mode label
const modeEl = document.getElementById('hud-mode');
if (modeEl) {
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
modeEl.textContent = MODE_LABELS[styleName]
? t(MODE_LABELS[styleName])
: styleName.toUpperCase();
}
// Update color scheme
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
+5 -3
View File
@@ -22,10 +22,12 @@ test('HUD sliders and location search have descriptive explicit names', () => {
});
test('the first-run checkbox keeps its native visible label', () => {
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>Don't show this again<\/span>\s*<\/label>/);
// The span may carry a data-i18n extraction attribute; the native wrapping
// label contract is what this guards.
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span\b[^>]*>Don't show this again<\/span>\s*<\/label>/);
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
});
+23 -3
View File
@@ -23,6 +23,14 @@
*/
export const NEAR_POI_MAX_KM = 150;
/** The English locality line; the HUD passes hudLocalityFormat.js instead. */
const ENGLISH_LOCALITY = Object.freeze({
near: ({ poi, city, distKm }) =>
`NEAR ${poi.toUpperCase()} (${city.toUpperCase()}) ${Math.round(distKm)}KM`,
sector: ({ latDeg, lonDeg }) =>
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
});
/**
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
* @param {number} value Signed decimal degrees.
@@ -42,13 +50,25 @@ function coordinateTag(value, positive, negative) {
* catalogue is empty.
* @param {number} latDeg Camera latitude in decimal degrees.
* @param {number} lonDeg Camera longitude in decimal degrees.
* @param {{near: Function, sector: Function}} [format] Builds the line from
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as
* catalogued (`Tokyo`), not upper-cased.
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
* otherwise `SECTOR <lat> <lon>`.
*/
export function composeLocalityTag(nearest, latDeg, lonDeg) {
export function composeLocalityTag(
nearest,
latDeg,
lonDeg,
format = ENGLISH_LOCALITY,
) {
const distKm = Number(nearest?.distKm);
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
return format.near({
poi: String(nearest.poi),
city: String(nearest.city),
distKm,
});
}
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
return format.sector({ latDeg, lonDeg });
}
+70 -9
View File
@@ -6,6 +6,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js';
import { LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { setLocale } from './i18n/index.js';
const ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' };
@@ -17,7 +19,7 @@ test('the NEAR bound is metro scale, not continental', () => {
test('a landmark under the camera reads NEAR', () => {
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
);
assert.equal(
@@ -40,20 +42,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
});
test('the boundary is pinned on both sides, inclusive at the bound', () => {
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
const at = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
40,
-78,
);
assert.match(
at,
/^NEAR LINCOLN MEMORIAL/,
'exactly at the bound still reads NEAR',
);
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
const just_under = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
40,
-78,
);
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
const just_over = composeLocalityTag(
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
40,
-78,
);
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
});
test('southern and western hemispheres carry the right suffixes', () => {
// Rio and Honolulu — the two the fallback already handled correctly in the field.
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
assert.equal(
composeLocalityTag(null, -22.9068, -43.1729),
'SECTOR 22.91S 43.17W',
);
assert.equal(
composeLocalityTag(null, 21.3069, -157.8583),
'SECTOR 21.31N 157.86W',
);
});
// The tests above all pass against a hud.js that still computes the tag inline —
@@ -72,7 +96,9 @@ test('hud.js actually composes its summary through this helper', () => {
'hud.js must import composeLocalityTag from ./hudLocality.js',
);
assert.equal(
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
has(
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
),
true,
'_composeSummary must build its locality tag through composeLocalityTag()',
);
@@ -91,6 +117,41 @@ test('hud.js actually composes its summary through this helper', () => {
test('a missing or malformed nearest POI never crashes the summary', () => {
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
assert.match(
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
/^SECTOR /,
);
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
});
test('a formatter builds the line from the same parts', () => {
const format = {
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
};
assert.equal(
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
'near:Alcatraz Island|San Francisco|2.4',
);
assert.equal(
composeLocalityTag(null, 21.3, -157.8, format),
'sector:21.3|-157.8',
);
});
test('the HUD formatter translates the catalogued city, then upper-cases it', () => {
const tokyo = { poi: 'Tokyo Tower', city: 'Tokyo', distKm: 2 };
assert.equal(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
'NEAR TOKYO TOWER (TOKYO) 2KM',
);
setLocale('ru');
try {
assert.match(
composeLocalityTag(tokyo, 35.66, 139.75, LOCALIZED_LOCALITY),
/\(ТОКИО\)/,
);
} finally {
setLocale('en');
}
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @module hudLocalityFormat
* @description The HUD summary's locality line in the active language — the
* formatter `hud.js` hands to `composeLocalityTag`. Kept apart from `hud.js`
* (which imports `mgrs`, not loadable under plain Node) so it is unit-testable.
*/
import { formatNumber, getLocale, t } from './i18n/index.js';
import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export 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). */
export const LOCALIZED_LOCALITY = Object.freeze({
// Translate the catalogued name (`Tokyo`), then upper-case it in the page's
// language: the place-name table knows `Tokyo`, not `TOKYO`.
near: ({ poi, city, distKm }) =>
t('NEAR {poi} ({city}) {distance}', {
poi: poi.toLocaleUpperCase(getLocale()),
city: localizePlaceName(city).toLocaleUpperCase(getLocale()),
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')),
}),
});
+7 -5
View File
@@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) {
}
/**
* Deterministic telemetry suffix when an enabled feed is not nominal.
* The feed state and up to two layer names for the HUD telemetry suffix when
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
* id, `names` are `layer.name`); the HUD translates them for display.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {string|null}
* @returns {{ state: string, names: string[] }|null}
*/
export function hudTelemetryProvenanceTag(layers = [], options) {
export function hudTelemetryProvenance(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
.map((s) => String(s.name || s.id || 'LAYER'))
.slice(0, 2);
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
return { state: envelope.overall, names };
}
/** Require the supplied non-nominal state before showing an AI summary. */
+101 -29
View File
@@ -5,7 +5,7 @@ import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
function invokeRoute(
handler,
{ method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const headers = new Map();
const req = {
@@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => {
test('recognizes only the exact deliberate no-key fallback response', () => {
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
assert.equal(isHudSummaryUnconfigured(200, {
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
error: 'provider failed',
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
summary: 'Unexpected provider output',
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
configured: true,
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
...UNCONFIGURED_PAYLOAD,
unexpected: true,
}), false);
assert.equal(isHudSummaryUnconfigured(200, {
}),
false,
);
assert.equal(
isHudSummaryUnconfigured(200, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}), false);
}),
false,
);
});
test('does not hide real provider and HTTP failures', () => {
assert.equal(isHudSummaryUnconfigured(502, {
assert.equal(
isHudSummaryUnconfigured(502, {
code: HUD_SUMMARY_UNCONFIGURED_CODE,
}), false);
}),
false,
);
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
assert.equal(
isHudSummaryUnconfigured(200, { error: 'provider failed' }),
false,
);
});
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
@@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await invokeRoute(hud, { method: 'POST' });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(
response.headers['content-type'],
'application/json; charset=utf-8',
);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
}
} finally {
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = previousKey;
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
if (previousLimit === undefined)
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
}
});
@@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
stats: {
stale: true,
count: 12,
lastUpdate: now - 240_000,
source: 'OpenSky Network',
},
},
{
id: 'earthquakes',
@@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
assert.equal(context.enabledLayers[0].feedState, 'stale');
assert.equal(context.feedProvenance.overall, 'stale');
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
assert.equal(hudTelemetryProvenanceTag([{
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
}], { now }), null);
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
state: 'stale',
names: ['Live Flights'],
});
assert.equal(
hudTelemetryProvenance(
[
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { count: 4, lastUpdate: now },
},
],
{ now },
),
null,
);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/five words MUST include that feedState token/,
);
assert.match(
HUD_SUMMARY_INSTRUCTIONS,
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
const local = readFileSync(
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
'utf8',
);
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
const { hudSummaryMatchesProvenance } =
await import('./hudSummaryResponse.js');
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'stale',
}),
false,
);
assert.equal(
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
overall: 'stale',
}),
true,
);
assert.equal(
hudSummaryMatchesProvenance('Austin flights operating normally today', {
overall: 'nominal',
}),
true,
);
});
+280
View File
@@ -0,0 +1,280 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
findUnmarked,
readCatalog,
shippedLocales,
unmarkedMarkup,
unmarkedScript,
} from '../../scripts/i18n.mjs';
import { LOCALES } from './locale.js';
const messages = extractMessages();
test('every catalog file is a shipped locale and every non-English locale has a catalog', () => {
assert.deepEqual(
shippedLocales().sort(),
LOCALES.filter((locale) => locale !== 'en').sort(),
);
});
for (const locale of shippedLocales()) {
test(`${locale} catalog matches the code (npm run i18n)`, () => {
const { missing, stale, errors } = checkCatalog(
locale,
readCatalog(locale),
messages,
);
assert.deepEqual(
{ missing, stale, errors },
{ missing: [], stale: [], errors: [] },
);
});
}
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
assert.deepEqual(
findUnmarked().map(({ where, text }) => `${where} ${text}`),
[],
);
});
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
const fixture = new Map([
['Saved {name}', { plural: false }],
['{count} keys', { plural: true, one: '{count} key' }],
['Untranslated', { plural: false }],
]);
const result = checkCatalog(
'ru',
{
'Saved {name}': 'Сохранено {nmae}',
'{count} keys': {
one: '{count} ключ',
few: '{count} ключа',
many: '{count} ключей',
},
Removed: 'Удалено',
},
fixture,
);
assert.deepEqual(result.missing, ['Untranslated']);
assert.deepEqual(result.stale, ['Removed']);
assert.equal(result.errors.length, 2);
});
test('checkCatalog reports a translation left in English unless it is allowed', () => {
const fixture = new Map([
['Bloom', { plural: false }],
['HDG', { plural: false }],
['voice|READY', { plural: false }],
['{state} · {source}', { plural: false }],
]);
const catalog = {
Bloom: 'Bloom',
HDG: 'HDG',
'voice|READY': 'READY',
'{state} · {source}': '{state} · {source}',
};
const errors = (allowed) =>
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
assert.deepEqual(
errors([]).map((error) => error.split(' is still English')[0]),
['"Bloom"', '"HDG"', '"voice|READY"'],
);
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
});
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
'title="Pin it"',
]);
assert.deepEqual(
texts('<button title="Pin it" data-i18n-title>x</button>'),
[],
);
assert.deepEqual(
texts('<select translate="no"><option>0.9 dB</option></select>'),
[],
);
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
assert.deepEqual(
texts('<span class="material-symbols-outlined">public</span>'),
[],
);
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
});
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
'On air',
'Off air',
]);
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
'Open',
]);
assert.deepEqual(
texts("const row = { label: msg('Wind'), id: 'wind' };"),
[],
);
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
assert.deepEqual(texts("el.className = 'is-active';"), []);
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
assert.deepEqual(
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
[],
);
});
test('unmarkedScript sees toasts behind optional calls and private helpers', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("showToast?.('North up');"), ['North up']);
assert.deepEqual(texts("this._showToast('Search failed');"), [
'Search failed',
]);
assert.deepEqual(texts("this.showToast('Link copied');"), ['Link copied']);
assert.deepEqual(texts("this.actions.showToast?.('Tilted view');"), [
'Tilted view',
]);
assert.deepEqual(texts("showToast?.(t('North up'));"), []);
assert.deepEqual(texts("this._showToast(t('Search failed'));"), []);
});
test('unmarkedScript sees status text assigned to layer errors and labels', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(texts("layerState._error = 'live feed unavailable';"), [
'live feed unavailable',
]);
assert.deepEqual(
texts("this._error = err?.message || 'TeleGeography load failed';"),
['TeleGeography load failed'],
);
assert.deepEqual(texts("_error = 'Route request failed';"), [
'Route request failed',
]);
assert.deepEqual(texts("error ??= 'Feed closed';"), ['Feed closed']);
assert.deepEqual(texts('stats.error = `${name} fetch error`;'), [
'fetch error',
]);
assert.deepEqual(texts("loadingLabel = 'loading coverage...';"), [
'loading coverage...',
]);
assert.deepEqual(texts("state._loadingLabel = 'Loading cables';"), [
'Loading cables',
]);
assert.deepEqual(texts("stats.statusMessage = 'Add a key in Settings';"), [
'Add a key in Settings',
]);
assert.deepEqual(texts("this.status = 'Scene not saved';"), [
'Scene not saved',
]);
assert.deepEqual(
texts("layerState._error = msg('live feed unavailable');"),
[],
);
assert.deepEqual(texts("stats.error = t('GBFS fetch error');"), []);
// Machine values, comparisons and local scratch variables are not UI text.
assert.deepEqual(texts("status = 'missing-key';"), []);
assert.deepEqual(texts("if (error === 'Feed closed') stop();"), []);
assert.deepEqual(texts("const error = log() || 'Unknown shader error';"), []);
assert.deepEqual(texts('stats.error = new Error(`HTTP ${code}`);'), []);
});
test('unmarkedScript sees human-readable status, loadingLabel and statusMessage fields', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts("return { status: 'Scene not saved — storage unavailable' };"),
['Scene not saved — storage unavailable'],
);
assert.deepEqual(
texts("return { status: ok ? 'Ready' : 'Source unavailable' };"),
['Ready', 'Source unavailable'],
);
assert.deepEqual(texts("return { loadingLabel: 'Loading feeds' };"), [
'Loading feeds',
]);
assert.deepEqual(texts("return { statusMessage: 'connect a receiver' };"), [
'connect a receiver',
]);
assert.deepEqual(texts("return { status: 'streaming', code: 'ok' };"), []);
assert.deepEqual(texts("return { status: 'auth-failed' };"), []);
assert.deepEqual(texts("return { status: msg('Source unavailable') };"), []);
});
// The Layers panel shows `stats.error || stats.lastError`, so a
// lastError is status text like error.
test('unmarkedScript sees status text assigned to lastError', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || 'Coverage tile failed';",
),
['Coverage tile failed'],
);
assert.deepEqual(texts("layerState._lastError = 'CelesTrak unreachable';"), [
'CelesTrak unreachable',
]);
assert.deepEqual(
texts(
"state.coverage.lastError = error?.message || msg('Coverage tile failed');",
),
[],
);
assert.deepEqual(texts('this._lastError = message;'), []);
});
test('unmarkedScript skips default values and ternary branches, not later assignments', () => {
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
// A default value in a parameter list or destructuring is a local's.
assert.deepEqual(texts("function f(error = 'Feed went away') {}"), []);
assert.deepEqual(texts("const f = (status = 'Not ready', x) => x;"), []);
assert.deepEqual(
texts("function f(\n status = 'Not ready',\n x,\n) {}"),
[],
);
assert.deepEqual(texts("const { status = 'Ready now' } = options;"), []);
assert.deepEqual(
texts("const {\n error = 'No data yet',\n} = options;"),
[],
);
assert.deepEqual(
texts("for (const [id, status = 'Waiting'] of rows) {}"),
[],
);
// A name right after `?` is a ternary branch, not a `status:` property.
assert.deepEqual(texts("const s = ok ? status : 'Source unavailable';"), []);
assert.deepEqual(texts("const s = ok\n ? title\n : 'Untitled view';"), []);
// …while the property itself still is.
assert.deepEqual(texts("return { title: x ? title : 'Untitled view' };"), [
'Untitled view',
]);
// A later assignment is checked even when the name is a declared local:
// a layer keeps its status in `let error` (wind, AIS watchdog) and builds
// `loadingLabel` in a `let` before returning it (FIRMS).
assert.deepEqual(
texts(
"function f() {\n let error = null;\n if (x) error = 'Feed closed';\n}",
),
['Feed closed'],
);
assert.deepEqual(
texts(
"let loadingLabel = '';\nif (x) {\n loadingLabel = 'KEY REQUIRED';\n}",
),
['KEY REQUIRED'],
);
assert.deepEqual(texts("let a = 1,\n error = 'Feed closed';"), [
'Feed closed',
]);
});
+62
View File
@@ -0,0 +1,62 @@
// An empty or blank translation must not count as done.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
readCatalog,
} from '../../scripts/i18n.mjs';
import { getCatalog, setLocale, t, tn, translateKnown } from './index.js';
const messages = extractMessages();
const EMPTY = 'DISCONNECT';
const BLANK = 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
for (const locale of ['es', 'ru']) {
test(`${locale}: checkCatalog rejects an empty or blank translation`, () => {
const catalog = { ...readCatalog(locale), [EMPTY]: '', [BLANK]: ' ' };
const { errors } = checkCatalog(locale, catalog, messages);
assert.ok(
errors.some((e) => e.includes(`"${EMPTY}"`)),
'empty string passes the check',
);
assert.ok(
errors.some((e) => e.includes(`"${BLANK}"`)),
'blank string passes the check',
);
});
}
test('t() and translateKnown() fall back to English for an empty entry', () => {
const es = getCatalog('es');
const saved = es[EMPTY];
es[EMPTY] = '';
setLocale('es');
try {
assert.equal(t(EMPTY), EMPTY);
assert.equal(translateKnown(EMPTY), EMPTY);
} finally {
es[EMPTY] = saved;
setLocale('en');
}
});
test('tn() skips an empty plural form', () => {
const es = getCatalog('es');
const KEY = '{count} INPUTS UNKNOWN';
const saved = es[KEY];
setLocale('es');
try {
es[KEY] = { ...saved, one: ' ' };
assert.equal(
tn(1, '{count} INPUT UNKNOWN', KEY),
saved.other.replace('{count}', '1'),
);
es[KEY] = { one: '', many: '', other: '' };
assert.equal(tn(1, '{count} INPUT UNKNOWN', KEY), '1 INPUT UNKNOWN');
assert.equal(tn(3, '{count} INPUT UNKNOWN', KEY), '3 INPUTS UNKNOWN');
} finally {
es[KEY] = saved;
setLocale('en');
}
});
+252
View File
@@ -0,0 +1,252 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
applyDocumentTranslations,
formatDate,
formatNumber,
msg,
persistLocaleAndReload,
setLocale,
t,
tn,
} from './index.js';
import {
LOCALE_STORAGE_KEY,
applyDocumentLanguage,
normalizeLocale,
resolveLocale,
writeStoredLocale,
} from './locale.js';
afterEach(() => setLocale('en'));
function memoryStorage(initial = {}) {
const data = new Map(Object.entries(initial));
return {
getItem: (key) => (data.has(key) ? data.get(key) : null),
setItem: (key, value) => data.set(key, String(value)),
data,
};
}
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale(undefined), null);
});
test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
assert.equal(
resolveLocale({
location: { search: '?lang=en' },
storage,
languages: ['ru'],
}),
'en',
);
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '?lang=xx' },
storage: memoryStorage(),
languages: ['de-DE', 'ru-RU'],
}),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
}),
'en',
);
});
test('a throwing storage never escapes', () => {
const storage = {
getItem() {
throw new Error('blocked');
},
setItem() {
throw new Error('blocked');
},
};
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: [] }),
'en',
);
assert.equal(writeStoredLocale('ru', storage), false);
});
test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
assert.equal(t('POWERED UP'), 'POWERED UP');
setLocale('ru');
assert.notEqual(t('POWERED UP'), 'POWERED UP');
});
test('t() interpolates named values and keeps unknown names literal', () => {
assert.equal(
t('Save failed: {detail}', { detail: 'disk full' }),
'Save failed: disk full',
);
assert.equal(
t('Hello {who} {missing}', { who: 'pilot' }),
'Hello pilot {missing}',
);
});
test('t() falls back to English for text without a translation', () => {
setLocale('ru');
assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1');
});
test('tn() picks English and Russian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
assert.equal(label(1), 'POWER UP · 1 KEY WAITING');
assert.equal(label(3), 'POWER UP · 3 KEYS WAITING');
setLocale('ru');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/);
assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/);
assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru');
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
});
test('msg() returns its text unchanged', () => {
assert.equal(msg('Live Flights'), 'Live Flights');
});
test('Intl formatters follow the active locale', () => {
const date = new Date(Date.UTC(2026, 0, 15));
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
assert.equal(formatDate(date, options), 'January 15, 2026');
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatNumber(1234.5), '1 234,5');
});
function fakeElement({ text = '', attributes = {} } = {}) {
const attrs = new Map(Object.entries(attributes));
return {
textContent: text,
getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null),
setAttribute: (name, value) => attrs.set(name, value),
attrs,
};
}
test('applyDocumentTranslations translates opted-in text and attributes in place', () => {
const label = fakeElement({ text: '\n POWERED\n UP ' });
const pin = fakeElement({ attributes: { title: 'POWERED UP' } });
const unknown = fakeElement({ text: 'No such message' });
const doc = {
querySelectorAll(selector) {
if (selector === '[data-i18n]') return [label, unknown];
if (selector === '[data-i18n-title]') return [pin];
return [];
},
};
assert.equal(
applyDocumentTranslations(doc),
0,
'English leaves markup alone',
);
setLocale('ru');
assert.equal(applyDocumentTranslations(doc), 2);
assert.equal(label.textContent, t('POWERED UP'));
assert.equal(pin.attrs.get('title'), t('POWERED UP'));
assert.equal(unknown.textContent, 'No such message');
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
href: 'https://gev.local/?x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
const storage = {
setItem() {
throw new Error('blocked');
},
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
['reload'],
]);
});
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
const storage = memoryStorage();
const calls = [];
const location = {
href: 'https://gev.local/?lang=en&x=1#scene=a',
assign: (url) => calls.push(['assign', url]),
reload: () => calls.push(['reload']),
};
const history = {
replaceState: (_s, _t, url) => calls.push(['replace', url]),
};
assert.equal(
persistLocaleAndReload('ru', { location, history, storage }),
true,
);
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru');
assert.deepEqual(calls, [
['replace', 'https://gev.local/?x=1#scene=a'],
['reload'],
]);
});
test('tn() shows a caller-formatted count and still picks the form by number', () => {
const label = (n) =>
tn(n, '{count} map object', '{count} map objects', {
count: formatNumber(n),
});
assert.equal(label(12345), '12,345 map objects');
assert.equal(tn(12345, '{count} file', '{count} files'), '12345 files');
setLocale('ru');
try {
assert.equal(label(12345), '12 345 объектов на карте');
assert.equal(label(1), '1 объект на карте');
} finally {
setLocale('en');
}
});
+330
View File
@@ -0,0 +1,330 @@
// God's Eye View i18n: the English source text is the message key.
//
// t('Save keys') → 'Сохранить ключи'
// t('Cleared {name}', { name }) → named interpolation
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
// t('CLEAR', { context: 'action' }) → same English, own entry
// msg('Live Flights') → marks a string kept in data
// for a later t(); returns it as is
//
// English needs no catalog. Every other locale is one flat JSON file,
// locales/<code>.json, keyed by the English text (for tn, by the English
// plural form). A missing or empty entry falls back to English.
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru });
let currentLocale = DEFAULT_LOCALE;
/** The catalog for a locale; null for English and unknown codes. */
export function getCatalog(locale) {
return CATALOGS[normalizeLocale(locale)] ?? null;
}
/** Active locale. */
export function getLocale() {
return currentLocale;
}
/**
* Switch the active locale. Pure state change: call applyDocumentLanguage /
* applyDocumentTranslations to reflect it in the DOM.
* @returns {string} The locale actually applied (normalized).
*/
export function setLocale(locale) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
if (normalized !== currentLocale) {
currentLocale = normalized;
formatterCache.clear();
}
return currentLocale;
}
/** Development-only warning; silent under node:test and in production. */
function warnMissing(text) {
if (import.meta.env?.DEV !== true) return;
console.warn(
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
);
}
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
/** Named {placeholder} interpolation. Unknown names stay literal so a gap
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
}
/** A catalog string with something to show; an empty one counts as missing. */
const filled = (value) => typeof value === 'string' && value.trim() !== '';
function lookup(text) {
const catalog = CATALOGS[currentLocale];
if (!catalog) return undefined;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
warnMissing(text);
return undefined;
}
/**
* Translate English source text into the active locale.
* @param {string} text English text, e.g. 'Hold Space to talk'
* @param {object} [values] Named interpolation values. `context` tells
* apart identical English with different meanings: t('CLEAR', { context:
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
* @returns {string}
*/
export function t(text, values) {
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
return interpolate(typeof entry === 'string' ? entry : text, values);
}
/**
* Translate a count-dependent message. The catalog entry for `other` holds
* one form per Intl.PluralRules category of the locale ({ one, few, many, other }
* for Russian). `{count}` is always available to the pattern: the raw number,
* unless `values.count` gives its display text, e.g. `formatNumber(n)`.
* @param {number} count
* @param {string} one English singular, e.g. '{count} camera'
* @param {string} other English plural, e.g. '{count} cameras'
* @param {object} [values] Extra interpolation values; a `count` here is
* shown in place of the number (the plural form still follows `count`)
* @returns {string}
*/
export function tn(count, one, other, values) {
const entry = lookup(other);
const category = pluralRules(currentLocale).select(count);
let pattern;
const forms = entry && typeof entry === 'object' ? entry : null;
if (forms && (filled(forms[category]) || filled(forms.other))) {
pattern = filled(forms[category]) ? forms[category] : forms.other;
} else {
pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other;
}
return interpolate(pattern, { count, ...values });
}
/**
* 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 = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
return filled(entry) ? entry : text;
}
/** Mark English text that is stored in data and translated later by t(). */
export function msg(text) {
return text;
}
const pluralRulesCache = new Map();
function pluralRules(locale) {
let rules = pluralRulesCache.get(locale);
if (!rules) {
rules = new Intl.PluralRules(locale);
pluralRulesCache.set(locale, rules);
}
return rules;
}
/*
* Intl formatters are memoized per (kind, locale, options): the constructors
* are expensive and panels format on refresh paths. The cache is cleared
* whenever the active locale changes.
*/
const formatterCache = new Map();
function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
}
/** Locale-aware number formatting (Intl.NumberFormat). */
export function formatNumber(value, options) {
return cachedFormatter('number', Intl.NumberFormat, options).format(value);
}
/** Locale-aware date/time formatting (Intl.DateTimeFormat). */
export function formatDate(value, options) {
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
}
const UTC_DAY_MONTH = Object.freeze({
day: '2-digit',
month: '2-digit',
timeZone: 'UTC',
});
/**
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
* English, "13.10" in Russian, "13/10" in Spanish, never read as a
* range. Takes an ISO string (its date part is used as written) or a time.
* @param {string|number|Date} value
* @returns {string} '' when the value is not a time.
*/
export function formatUtcDayMonth(value) {
const written =
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
const time = written
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
}
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
export function normalizeMarkupText(text) {
return String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Markup opt-in: the element's own English text or attribute is the source.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
['data-i18n-title', 'title'],
['data-i18n-aria-label', 'aria-label'],
['data-i18n-placeholder', 'placeholder'],
]);
/**
* Translate every opted-in element in `doc`. Runs once at boot; switching
* locale reloads the page.
* @param {Document|Element} doc
* @returns {number} Count of element/attribute writes performed.
*/
export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
let applied = 0;
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${marker}]`)) {
const source = attribute
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
if (!source) continue;
const text = t(source);
if (text === source) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
applied += 1;
}
}
return applied;
}
const unsavedWorkChecks = new Set();
/**
* Name work that switching language would lose: the switch reloads the page,
* and the language switch asks first while any check reports something.
* @param {() => string|null|undefined} check Returns a msg() label (e.g.
* msg('unsaved CCTV calibration')) while there is such work, else nothing.
* @returns {() => void} Removes the check.
*/
export function registerUnsavedWork(check) {
if (typeof check !== 'function') return () => {};
unsavedWorkChecks.add(check);
return () => unsavedWorkChecks.delete(check);
}
/**
* What a reload would lose right now, translated, without repeats.
* @returns {string[]}
*/
export function describeUnsavedWork() {
const labels = new Set();
for (const check of unsavedWorkChecks) {
try {
const label = check();
if (typeof label === 'string' && label) labels.add(t(label));
} catch {
// A failing check must not block the switch.
}
}
return [...labels];
}
/**
* Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string, so the stored choice applies.
* Where storage is blocked, ?lang carries the choice instead (share links
* drop it: ShareLinkManager.copyLink).
* @returns {boolean} true when a reload was triggered.
*/
export function persistLocaleAndReload(
locale,
{
location = globalThis.location,
history = globalThis.history,
storage,
} = {},
) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
const stored = writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false;
let target = location.href;
try {
// Without storage the choice only survives in the URL, so keep it there.
const url = new URL(location.href);
if (stored) url.searchParams.delete('lang');
else url.searchParams.set('lang', normalized);
target = url.href;
} catch {
// Keep the current href.
}
// location.assign() to the identical URL does not navigate, so strip ?lang
// in place and force a real reload instead.
try {
history?.replaceState?.(null, '', target);
} catch {
// replaceState is best-effort; the reload below still applies.
}
if (typeof location.reload === 'function') {
location.reload();
} else {
location.assign(target);
}
return true;
}
+60
View File
@@ -0,0 +1,60 @@
// KartaView texts are English data (msg() in providers/kartaview/) shown
// through translateKnown, so each needs an entry in every catalog.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from './index.js';
const MESSAGES = [
// kartaview/policy.js
'KartaView is rate-limiting requests — they resume on their own',
'KartaView’s photos here are archived and cannot be shown',
'KartaView did not filter this area in time — move the map to ask again',
'None of KartaView’s photos here pass the imagery filter',
// kartaview/source.js
'KartaView did not answer in time — try again',
'KartaView could not be reached — check the network',
'KartaView sent an answer that could not be read',
'KartaView is rate-limiting requests',
'KartaView is rate-limiting requests — try again in a moment',
'KartaView sent too much data',
'KartaView has no such photo',
// kartaview/viewer.js, index.js
'The KartaView photo could not be loaded',
'KartaView has no position for this photo',
];
for (const locale of ['es', 'ru']) {
test(`KartaView provider messages are translated in ${locale}`, () => {
setLocale(locale);
try {
const untranslated = MESSAGES.filter((m) => translateKnown(m) === m);
assert.deepEqual(untranslated, []);
} finally {
setLocale('en');
}
});
}
// kartaview/source.js refusals with a number in them: English data, shown
// through placeholder entries by the Street Level panel and the layer panel.
test('KartaView HTTP and refusal codes keep their number in every language', async () => {
const { translateStreetLevelError } =
await import('../ui/streetLevelPresentation.js');
const http = 'KartaView answered HTTP 503';
const refused = 'KartaView refused the request (code 610)';
assert.equal(translateStreetLevelError(http), http);
assert.equal(translateStreetLevelError(refused), refused);
for (const locale of ['es', 'ru']) {
setLocale(locale);
try {
const shownHttp = translateStreetLevelError(http);
const shownRefused = translateStreetLevelError(refused);
assert.notEqual(shownHttp, http);
assert.match(shownHttp, /503/);
assert.notEqual(shownRefused, refused);
assert.match(shownRefused, /610/);
} finally {
setLocale('en');
}
}
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { setLocale, translateKnown } from './index.js';
// Layers keep these status texts as English data, and the panels show them
// through translateKnown, so each needs an entry in every catalog.
const SITES = [
['../layers/streetLevel/index.js', 'loading coverage...'],
['../layers/firms/ingestion.js', 'live feed unavailable'],
['../layers/bikeshare/controls.js', 'GBFS fetch error'],
[
'../layers/streetLevel/viewerHost.js',
'Open the Street Level panel to view imagery',
],
['../layers/streetLevel/viewerHost.js', 'Image could not be opened'],
['../layers/satellites/ingestion.js', 'CelesTrak unreachable'],
[
'../layers/streetLevel/providers/mapillary/policy.js',
'Coverage requests are being rate-limited — they resume on their own',
],
['../layers/alpr/index.js', 'Camera source temporarily unavailable'],
['../layers/perimeters/index.js', 'Perimeter source unavailable'],
['../layers/recentImagery/index.js', 'Imagery catalog unavailable'],
['../layers/installations/ingestion.js', 'Installation context unavailable'],
['../layers/earthquakes/index.js', 'Earthquake source unavailable'],
['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'],
['../layers/vessels/ingestion.js', 'AIS live load failed'],
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
['../layers/wind/index.js', 'Wind unavailable'],
['../layers/wind/source.js', 'Wind request timed out'],
['../layers/wind/source.js', 'Malformed wind manifest'],
['../layers/wind/source.js', 'Malformed wind grid'],
['../layers/wind/fields.js', 'Temperature field unavailable'],
['../layers/wind/fields.js', 'Mean sea level pressure field unavailable'],
];
// A fallback that reaches the panel must be marked, or `npm run i18n` cannot
// see it and the catalog entry would be pruned as unused.
for (const [file, text] of SITES) {
test(`"${text}" in ${file} is marked`, () => {
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
assert.ok(
new RegExp(
String.raw`\b(?:msg|t)\(\s*'${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'`,
).test(source),
`${text} is not marked in ${file}`,
);
});
}
for (const locale of ['es', 'ru']) {
for (const [file, text] of SITES) {
test(`${locale}: "${text}" from ${file} is translated`, () => {
const source = readFileSync(new URL(file, import.meta.url), 'utf8');
assert.ok(source.includes(`'${text}'`), `literal not found in ${file}`);
setLocale(locale);
try {
assert.notEqual(translateKnown(text), text);
} finally {
setLocale('en');
}
});
}
}
+88
View File
@@ -0,0 +1,88 @@
// Which locale the page runs in: ?lang= → stored choice → browser languages →
// English. Only shipped locales are ever selected.
/** Durable preference key. Matches the repo convention gev:<name>:v1. */
export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
/** Source language and fallback. */
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ru: 'Русский',
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* @returns {string|null}
*/
export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
return LOCALES.includes(primary) ? primary : null;
}
function queryLocale(location) {
try {
return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
} catch {
return null;
}
}
function readStoredLocale(storage) {
try {
const store = storage !== undefined ? storage : globalThis.localStorage;
return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
} catch {
return null;
}
}
/** Store the choice; false when storage is unavailable. */
export function writeStoredLocale(locale, storage) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
try {
const store = storage !== undefined ? storage : globalThis.localStorage;
if (typeof store?.setItem !== 'function') return false;
store.setItem(LOCALE_STORAGE_KEY, normalized);
return true;
} catch {
return false;
}
}
/** Resolve the boot locale. Every input is injectable for tests. */
export function resolveLocale({
location = globalThis.location,
storage,
languages = globalThis.navigator?.languages,
} = {}) {
const fromQuery = queryLocale(location);
if (fromQuery) return fromQuery;
const stored = readStoredLocale(storage);
if (stored) return stored;
for (const candidate of Array.isArray(languages) ? languages : []) {
const normalized = normalizeLocale(candidate);
if (normalized) return normalized;
}
return DEFAULT_LOCALE;
}
/** Set <html lang> and <html dir>. */
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
const root = doc?.documentElement;
if (!root) return false;
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
root.lang = normalized;
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
return true;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
// msg() for modules the server, tools or other portable code also import:
// marks English that stays in data for `npm run i18n`, without loading the
// catalogs (src/i18n/index.js). The UI shows it later through t().
// Browser-only modules import msg from src/i18n/index.js instead.
/** Mark English text that is stored in data and translated later by t(). */
export const msg = (text) => text;
+89
View File
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
checkCatalog,
extractMessages,
missingTemplate,
} from '../../scripts/i18n.mjs';
// A plural entry with forms left in English is reported like a string entry.
const fixture = new Map([
['{count} cameras', { plural: true, one: '{count} camera' }],
['Save keys', { plural: false }],
]);
test('string entry left in English is reported (control)', () => {
const { errors } = checkCatalog(
'de',
{
'Save keys': 'Save keys',
'{count} cameras': { one: 'x {count}', other: 'y {count}' },
},
fixture,
new Set(),
);
assert.equal(errors.length, 1);
assert.match(errors[0], /is still English/);
});
for (const [locale, entry] of [
['de', { one: '{count} camera', other: '{count} cameras' }],
['ja', { other: '{count} cameras' }],
['zh-CN', { other: '{count} cameras' }],
[
'es',
{
one: '{count} camera',
many: '{count} cámaras',
other: '{count} cameras',
},
],
[
'ru',
{
one: '{count} camera',
few: '{count} камеры',
many: '{count} камер',
other: '{count} cameras',
},
],
]) {
test(`${locale}: plural entry with English forms is reported`, () => {
const { errors } = checkCatalog(
locale,
{ 'Save keys': 'Schlüssel', '{count} cameras': entry },
fixture,
new Set(),
);
assert.ok(
errors.some((e) => /is still English/.test(e)),
`expected an "is still English" error, got ${JSON.stringify(errors)}`,
);
});
}
// The `--missing` template, pasted without edits, must fail entry by entry,
// and its plural entries carry every form the language needs.
const messages = extractMessages();
for (const locale of ['de', 'ja', 'es', 'ru']) {
test(`${locale}: an untouched --missing template fails the check entry by entry`, () => {
const template = missingTemplate(locale, {}, messages);
assert.equal(Object.keys(template).length, messages.size);
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
for (const [text, info] of messages)
if (info.plural)
assert.deepEqual(Object.keys(template[text]), categories, text);
const { missing, errors } = checkCatalog(
locale,
template,
messages,
new Set(),
);
assert.deepEqual(missing, []);
const accepted = [...messages.keys()].filter(
(text) => !errors.some((error) => error.startsWith(`"${text}" `)),
);
assert.deepEqual(accepted, []);
});
}
+137
View File
@@ -0,0 +1,137 @@
{
"es": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"ANIME",
"ASIA",
"AUTO",
"Ambient",
"Anime",
"Austin",
"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",
"SQUAWK {code} · {status}",
"SRC {value}",
"SSE",
"STD",
"San Francisco",
"Sector",
"Sonar",
"Soul",
"Techno",
"Trance",
"USB {rate} msg/s",
"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",
"{rate} msg/s",
"{speed} kn",
"{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"
]
}
+51
View File
@@ -0,0 +1,51 @@
// scripts/i18n.mjs must skip index entries gone from the working tree
// (plain `mv`/`rm` without `git mv`/`git rm`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
copyFileSync,
renameSync,
rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
function sandbox() {
const root = mkdtempSync(path.join(tmpdir(), 'k23-'));
mkdirSync(path.join(root, 'scripts'));
mkdirSync(path.join(root, 'src/i18n/locales'), { recursive: true });
copyFileSync(
path.join(HERE, '../../scripts/i18n.mjs'),
path.join(root, 'scripts/i18n.mjs'),
);
writeFileSync(path.join(root, 'src/a.js'), "export const x = t('Hello');\n");
writeFileSync(path.join(root, 'index.html'), '<p data-i18n>Hello</p>\n');
const git = (...args) =>
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
git('init', '-q');
git('add', '-A');
git('-c', 'user.email=k@23', '-c', 'user.name=k23', 'commit', '-qm', 'init');
return root;
}
test('extractMessages survives a module renamed with plain mv', async () => {
const root = sandbox();
try {
renameSync(path.join(root, 'src/a.js'), path.join(root, 'src/b.js'));
const mod = await import(
pathToFileURL(path.join(root, 'scripts/i18n.mjs')).href
);
const messages = mod.extractMessages();
assert.ok(messages.has('Hello'));
assert.doesNotThrow(() => mod.findUnmarked());
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+203 -53
View File
@@ -20,12 +20,22 @@ import {
* the chip and the dialog are removed outright.
*/
/** Chip label — pure, exported for tests. */
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
/**
* Chip label — pure, exported for tests (keySetup.test.mjs pins the English).
* @param {{total?: number, setCount?: number}|null} status
* @returns {string}
*/
export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
: 'POWERED UP';
? tn(
missing,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
)
: t('POWERED UP');
}
/**
@@ -42,6 +52,73 @@ export function collectKeyUpdates(fields) {
return updates;
}
/**
* Why /api/setup/keys refused a save, by the `code` the server sends beside
* its English `error` (src/keySetupCore.mjs, server/standalone/key-setup.js).
* The English matches the server's text.
*/
const SAVE_REFUSALS = Object.freeze({
proxied: msg('Provider Settings does not answer proxied requests'),
sharing: msg('Provider Settings is disabled while sharing is enabled'),
'not-loopback': msg(
'Provider Settings answers only the machine running the server',
),
'not-local-host': msg('Provider Settings answers only local hostnames'),
'origin-required': msg('Provider Settings requires an exact local Origin'),
'origin-unrecognized': msg('Unrecognized Origin refused'),
'cross-origin': msg('Cross-origin requests are refused'),
'content-type': msg('Content-Type must be application/json'),
method: msg('Method not allowed'),
'too-large': msg('Request too large'),
'invalid-json': msg('Invalid JSON'),
'not-object': msg('Body must be a JSON object of {ENV_VAR: value}'),
'no-keys': msg('No keys provided'),
'too-many': msg('At most {limit} keys per save'),
'unknown-key': msg('Unknown key: {name}'),
'not-string': msg('{name} must be a string'),
empty: msg('{name} is empty'),
'too-long': msg('{name} is longer than any real key ({limit} max)'),
'not-printable': msg(
'{name} may only contain printable characters with no spaces',
),
'invalid-char': msg(
'{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)',
),
external: msg(
'{name} is configured outside Provider Settings and can only be changed where it was set',
),
'harden-failed': msg(
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
),
'store-unreadable': msg(
'The key was not saved: the existing configuration could not be read, so nothing was changed',
),
'write-failed': msg('Could not write the {store} store'),
});
/**
* The status line for a refused save — pure, exported for tests. A known
* `code` reads in the active language; anything else shows the server's text.
* @param {{code?: string, params?: object, error?: string}} payload
* @param {number} status HTTP status of the response.
* @returns {string}
*/
export function saveRefusalText(payload, status) {
const pattern = Object.hasOwn(SAVE_REFUSALS, payload?.code)
? SAVE_REFUSALS[payload.code]
: null;
if (pattern) {
const values = {};
for (const name of ['name', 'limit', 'store']) {
const value = payload.params?.[name];
if (typeof value === 'string' || typeof value === 'number')
values[name] = value;
}
return t(pattern, values);
}
return payload?.error || t('Save failed ({status}).', { status });
}
function sleepWithSignal(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@@ -60,6 +137,34 @@ function sleepWithSignal(ms, signal) {
});
}
/**
* An OAuth check error. A server reason is later wrapped in «OAuth check
* failed: …»; our own translated fallback is flagged to show unwrapped.
* @param {unknown} serverError
* @param {string} fallback Already-translated fallback sentence.
* @returns {Error}
*/
function oauthCheckError(serverError, fallback) {
if (serverError) return new Error(String(serverError));
const error = new Error(fallback);
error.oauthFallback = true;
return error;
}
/**
* Status text for a failed OAuth check — pure, exported for tests. A fallback
* sentence of our own already says what failed and is shown alone; any other
* reason (server text, network error) gets the «OAuth check failed» wrapper.
* @param {unknown} error
* @returns {string}
*/
export function oauthCheckFailureText(error) {
if (error?.oauthFallback && error.message) return error.message;
return t('OAuth check failed: {detail}', {
detail: error?.message || error,
});
}
export async function waitForChatGptOAuth({
fetchImpl,
signal,
@@ -78,8 +183,9 @@ export async function waitForChatGptOAuth({
if (signal?.aborted) return false;
if (response.ok && payload.available) return true;
if (!response.ok || payload.loginFailed) {
throw new Error(
payload.error || 'Could not check ChatGPT sign-in. Try again.',
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in. Try again.'),
);
}
if (now() >= deadline) break;
@@ -116,12 +222,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
const state = auth.querySelector('.key-setup-cloud-auth-state');
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
state.textContent =
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
mode === 'oauth'
? t('VOICE AUTH · CHATGPT OAUTH')
: t('VOICE AUTH · API KEY');
toggle.textContent =
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
toggle.title =
mode === 'oauth'
? 'Use OPENAI_API_KEY for the next cloud voice session'
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
? t('Use OPENAI_API_KEY for the next cloud voice session')
: t(
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
);
// Keep the control focusable while the delegated handler blocks repeats.
toggle.setAttribute('aria-disabled', String(busy));
}
@@ -141,21 +252,22 @@ function buildRow(documentRef, key) {
led.className = 'key-setup-led';
led.setAttribute('aria-hidden', 'true');
const title = documentRef.createElement('strong');
title.textContent = key.title;
title.textContent = key.title; // i18n-ignore: provider product name
const tier = documentRef.createElement('span');
tier.className = 'key-setup-tier';
tier.textContent = TIER_DOTS[key.tier] || '';
tier.title =
key.tier === 'metered'
? 'Metered — a billing-enabled account'
: 'Free key — register, paste, done';
? t('Metered — a billing-enabled account')
: t('Free key — register, paste, done');
head.append(led, title, tier);
if (key.clientExposed) {
const exposed = documentRef.createElement('span');
exposed.className = 'key-setup-exposed';
exposed.textContent = 'browser-side';
exposed.title =
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
exposed.textContent = t('browser-side');
exposed.title = t(
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
);
head.append(exposed);
}
if (external) {
@@ -163,9 +275,10 @@ function buildRow(documentRef, key) {
// facts this panel reports, never values it rewrites or deletes.
const badge = documentRef.createElement('span');
badge.className = 'key-setup-external';
badge.textContent = 'configured externally';
badge.title =
'Supplied by your environment, Keychain, or launcher — change it where it was set';
badge.textContent = t('configured externally');
badge.title = t(
'Supplied by your environment, Keychain, or launcher — change it where it was set',
);
head.append(badge);
}
const get = documentRef.createElement('a');
@@ -173,12 +286,12 @@ function buildRow(documentRef, key) {
get.href = key.getUrl;
get.target = '_blank';
get.rel = 'noopener noreferrer';
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
head.append(get);
const unlocks = documentRef.createElement('p');
unlocks.className = 'key-setup-unlocks';
unlocks.textContent = key.unlocks;
unlocks.textContent = t(key.unlocks);
row.append(head, unlocks);
if (!external) {
@@ -194,8 +307,8 @@ function buildRow(documentRef, key) {
input.dataset.envVar = envVar;
input.setAttribute('aria-label', envVar);
input.placeholder = key.set
? `${envVar} saved — paste to replace`
: `paste ${envVar}`;
? t('{envVar} saved — paste to replace', { envVar })
: t('paste {envVar}', { envVar });
fields.append(input);
}
if (key.managed === 'file') {
@@ -203,8 +316,10 @@ function buildRow(documentRef, key) {
remove.type = 'button';
remove.className = 'key-setup-remove';
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
remove.textContent = 'REMOVE';
remove.title = `Remove ${key.title} from this app's saved keys`;
remove.textContent = t('REMOVE');
remove.title = t("Remove {title} from this app's saved keys", {
title: key.title,
});
fields.append(remove);
}
row.append(fields);
@@ -225,17 +340,22 @@ function buildRow(documentRef, key) {
return row;
}
/** Move the existing setup action with the theme, retaining its listeners. */
export function bindKeySetupPlacement(documentRef, chip, root) {
/**
* Move the existing setup action with the theme, retaining its listeners:
* Cyber puts it in the top toolbar, every other theme keeps it first in its
* markup home (the bottom-right corner row, beside the language picker).
*/
export function bindKeySetupPlacement(documentRef, chip) {
const theme = documentRef?.documentElement;
const toolbar = documentRef?.getElementById?.('top-center-actions');
const Observer = documentRef?.defaultView?.MutationObserver;
if (!theme || !toolbar || !Observer) return () => {};
const home = chip?.parentNode;
if (!theme || !toolbar || !Observer || !home) return () => {};
const sync = () => {
if (theme.dataset.uiTheme === 'cyber') {
if (chip.parentNode !== toolbar) toolbar.append(chip);
} else if (chip.parentNode !== root.parentNode) {
root.before(chip);
} else if (chip.parentNode !== home) {
home.prepend(chip);
}
};
const observer = new Observer(sync);
@@ -261,9 +381,19 @@ export async function initKeySetup({
let disposed = false;
let disposeControls = () => {};
let disposePlacement = () => {};
// A pasted key lives only in its field until Save: switching language (a
// reload) asks first while any field holds one.
const unregisterUnsavedWork = registerUnsavedWork(() =>
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
String(input.value ?? '').trim(),
)
? msg('unsaved provider keys')
: null,
);
const destroy = () => {
if (disposed) return;
disposed = true;
unregisterUnsavedWork();
lifetime.abort();
signal?.removeEventListener('abort', destroy);
disposeControls();
@@ -295,7 +425,7 @@ export async function initKeySetup({
}
const rowsHost = root.querySelector('[data-key-setup-rows]');
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
disposePlacement = bindKeySetupPlacement(documentRef, chip);
const applyButton = root.querySelector('[data-key-setup-apply]');
const closeButton = root.querySelector('[data-key-setup-close]');
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
@@ -314,7 +444,9 @@ export async function initKeySetup({
if (disposed) return;
status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status);
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
chip.title = t('Project keys: {status}', {
status: keySetupChipLabel(status),
});
chip.setAttribute('aria-label', chip.title);
// Fully powered is the owner's clean screen: the chip retires. The dialog
// stays reachable this session (and via ?setup=1) to swap or verify keys.
@@ -369,17 +501,19 @@ export async function initKeySetup({
const storeLabel = () =>
status?.store === 'pinokio-environment'
? 'your app configuration'
: 'your local .env';
? t('your app configuration')
: t('your local .env');
const submitUpdates = async (updates, doneVerb) => {
// doneMessageKey names the whole saved/removed sentence; the storage
// destination is interpolated so the {store} phrase stays one spelling.
const submitUpdates = async (updates, doneMessageKey) => {
if (disposed || busy) return;
const googleWasUnset = !status?.keys?.find(
(key) => key.id === 'google-maps',
)?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
say('Saving…');
say(t('Saving…'));
try {
const response = await doFetch('/api/setup/keys', {
method: 'POST',
@@ -390,7 +524,7 @@ export async function initKeySetup({
const payload = await response.json().catch(() => ({}));
if (disposed) return;
if (!response.ok || !payload.ok) {
say(payload.error || `Save failed (${response.status}).`);
say(saveRefusalText(payload, response.status));
return;
}
for (const input of root.querySelectorAll('input[data-env-var]'))
@@ -420,11 +554,13 @@ export async function initKeySetup({
signal: lifetime.signal,
});
}
say(
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
);
say(t(doneMessageKey, { store: storeLabel() }));
} catch (error) {
say(`Save failed: ${error?.message || error}`);
say(
t('Save failed: {detail}', {
detail: error?.message || error,
}),
);
} finally {
busy = false;
applyButton?.setAttribute('aria-disabled', 'false');
@@ -441,10 +577,13 @@ export async function initKeySetup({
})),
);
if (!Object.keys(updates).length) {
say('Paste at least one key first.');
say(t('Paste at least one key first.'));
return;
}
await submitUpdates(updates, 'Saved to');
await submitUpdates(
updates,
msg('Saved to {store}. Restarting — this page reloads itself.'),
);
};
chip.addEventListener('click', openDialog);
@@ -462,12 +601,12 @@ export async function initKeySetup({
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncCloudVoiceAuth();
say('Cloud voice will use OPENAI_API_KEY on the next session.');
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
return;
}
oauthBusy = true;
syncCloudVoiceAuth();
say('Checking local ChatGPT OAuth sign-in…');
say(t('Checking local ChatGPT OAuth sign-in…'));
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
@@ -476,22 +615,25 @@ export async function initKeySetup({
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw new Error(
payload.error || 'Could not check ChatGPT sign-in.',
throw oauthCheckError(
payload.error,
t('Could not check ChatGPT sign-in.'),
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? 'ChatGPT sign-in expired. Opening sign-in again…'
: 'Opening ChatGPT sign-in in your browser…',
? t('ChatGPT sign-in expired. Opening sign-in again…')
: t('Opening ChatGPT sign-in in your browser…'),
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
@@ -504,7 +646,7 @@ export async function initKeySetup({
say(
loginPayload.error ||
payload.error ||
'Could not start ChatGPT sign-in on this machine.',
t('Could not start ChatGPT sign-in on this machine.'),
);
return;
}
@@ -512,13 +654,17 @@ export async function initKeySetup({
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
),
);
return;
}
say(
t(
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
),
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
@@ -527,18 +673,22 @@ export async function initKeySetup({
if (lifetime.signal.aborted) return;
if (!available) {
say(
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
t('ChatGPT sign-in timed out. Click {button} to try again.', {
button: t('USE CHATGPT OAUTH'),
}),
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
t(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
),
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(`OAuth check failed: ${error?.message || error}`);
say(oauthCheckFailureText(error));
} finally {
oauthBusy = false;
if (!disposed) syncCloudVoiceAuth();
@@ -560,11 +710,11 @@ export async function initKeySetup({
// a deliberate two-step the lure cannot pre-satisfy.
const ok =
typeof globalThis.confirm !== 'function' ||
globalThis.confirm('Remove this key from your saved configuration?');
globalThis.confirm(t('Remove this key from your saved configuration?'));
if (!ok) return;
void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])),
'Removed from',
msg('Removed from {store}. Restarting — this page reloads itself.'),
);
});
+66 -8
View File
@@ -6,29 +6,47 @@ import {
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
waitForChatGptOAuth,
initKeySetup,
} from './keySetup.js';
import { describeUnsavedWork } from './i18n/index.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = {};
const home = {
prepend: (node) => {
node.parentNode = home;
},
};
const chip = { parentNode: home, hidden: true };
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
const toolbar = {
append: (node) => {
moves++;
node.parentNode = toolbar;
},
};
const theme = { dataset: { uiTheme: 'tactical' } };
let notify, disconnected = false;
let notify,
disconnected = false;
class Observer {
constructor(callback) { notify = callback; }
constructor(callback) {
notify = callback;
}
observe(target, options) {
assert.equal(target, theme);
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
}
disconnect() { disconnected = true; }
disconnect() {
disconnected = true;
}
const dispose = bindKeySetupPlacement({
}
const dispose = bindKeySetupPlacement(
{
documentElement: theme,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
}, chip, root);
},
chip,
);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
@@ -156,3 +174,43 @@ test('aborting pending setup removes its surface and ignores a late response', a
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
assert.equal(await pending, null);
});
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
const node = () => ({
dataset: {},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
querySelector: () => null,
remove() {},
});
const chip = node();
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
const root = {
...node(),
querySelectorAll: (selector) =>
selector === 'input[data-env-var]' ? [input] : [],
};
const controller = new AbortController();
const setup = await initKeySetup({
documentRef: {
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
addEventListener() {},
removeEventListener() {},
},
signal: controller.signal,
fetchImpl: async () => ({
ok: true,
json: async () => ({ keys: [], setCount: 0, total: 1 }),
}),
});
assert.ok(setup);
assert.deepEqual(describeUnsavedWork(), []);
input.value = ' ';
assert.deepEqual(describeUnsavedWork(), []);
input.value = 'AIza-pasted';
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
// The field still holds the key, so only teardown dropping the check clears it.
controller.abort();
assert.deepEqual(describeUnsavedWork(), []);
});
+89 -29
View File
@@ -1,8 +1,9 @@
/**
* Key setup ("POWER UP") — the pure core.
*
* One registry and pure functions; its only import is the proxy-header check
* shared with the other local gates (localRequestGate.mjs). The dev server's
* One registry and pure functions; it imports only the proxy-header check
* shared with the other local gates (localRequestGate.mjs) and the msg()
* marker (src/i18n/marker.js). The dev server's
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
* are both thin shells over this module, so what a key is called, what it
* unlocks, and how a .env line is written each live in exactly one place.
@@ -12,6 +13,7 @@
* behavior below unit-testable.
*/
import { msg } from './i18n/marker.js';
import { hasProxySignals } from './localRequestGate.mjs';
/** Longest accepted key/token value. Real provider keys are all far shorter. */
@@ -35,8 +37,8 @@ export const KEY_SETUP_APPEND_HEADER =
export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS',
unlocks: 'The photorealistic 3D planet, place search + Street View',
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered',
@@ -44,8 +46,10 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'google-maps-server',
title: 'GOOGLE MAPS — SERVER',
unlocks: 'Places context + Street View fallback; optional separate key',
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
unlocks: msg(
'Places context + Street View fallback; optional separate key',
),
getUrl:
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
@@ -54,40 +58,40 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'openai',
title: 'OPENAI',
unlocks: 'Voice control — API key or ChatGPT OAuth',
title: 'OPENAI', // i18n-ignore: provider product name
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
}),
Object.freeze({
id: 'aisstream',
title: 'AISSTREAM',
unlocks: 'Live ships, worldwide',
title: 'AISSTREAM', // i18n-ignore: provider product name
unlocks: msg('Live ships, worldwide'),
getUrl: 'https://aisstream.io',
envVars: Object.freeze(['AISSTREAM_API_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'firms',
title: 'NASA FIRMS',
unlocks: 'Live active-fire detections',
title: 'NASA FIRMS', // i18n-ignore: provider product name
unlocks: msg('Live active-fire detections'),
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'tomtom',
title: 'TOMTOM',
unlocks: 'Real live traffic (keyless runs a simulation)',
title: 'TOMTOM', // i18n-ignore: provider product name
unlocks: msg('Real live traffic (keyless runs a simulation)'),
getUrl: 'https://my.tomtom.com/keys',
envVars: Object.freeze(['TOMTOM_API_KEY']),
tier: 'free',
}),
Object.freeze({
id: 'cesium-ion',
title: 'CESIUM ION',
unlocks: 'Bing imagery map stacks + world terrain',
title: 'CESIUM ION', // i18n-ignore: provider product name
unlocks: msg('Bing imagery map stacks + world terrain'),
getUrl: 'https://ion.cesium.com/tokens',
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
tier: 'free',
@@ -95,17 +99,18 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'opensky',
title: 'OPENSKY',
unlocks: 'More flight-polling credits (anonymous works without)',
title: 'OPENSKY', // i18n-ignore: provider product name
unlocks: msg('More flight-polling credits (anonymous works without)'),
getUrl: 'https://opensky-network.org',
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free',
}),
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY',
unlocks:
title: 'MAPILLARY', // i18n-ignore: provider product name
unlocks: msg(
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
),
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
@@ -113,8 +118,8 @@ export const KEY_SETUP_KEYS = Object.freeze([
}),
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY',
unlocks: 'Higher space-missions request allowance',
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
unlocks: msg('Higher space-missions request allowance'),
getUrl: 'https://thespacedevs.com',
envVars: Object.freeze(['LL2_API_TOKEN']),
tier: 'free',
@@ -262,7 +267,9 @@ export function isSharingEnabled(env = {}) {
* preflight this server never answers, closing the simple-request CSRF
* write primitive.
*
* @returns {{ok: true} | {ok: false, status: number, error: string}}
* A refusal also carries `code`, which the panel turns into its own localized
* sentence (keySetup.js); `error` stays the English for logs and API callers.
* @returns {{ok: true} | {ok: false, status: number, error: string, code: string}}
*/
export function admitKeySetupRequest({
method,
@@ -283,6 +290,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Provider Settings does not answer proxied requests',
code: 'proxied',
};
}
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
@@ -300,6 +308,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Provider Settings is disabled while sharing is enabled',
code: 'sharing',
};
}
if (
@@ -310,6 +319,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Provider Settings answers only the machine running the server',
code: 'not-loopback',
};
}
const authority = localAuthority(hostHeader, protocol);
@@ -318,6 +328,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Provider Settings answers only local hostnames',
code: 'not-local-host',
};
}
if (
@@ -328,6 +339,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Provider Settings requires an exact local Origin',
code: 'origin-required',
};
}
if (origin !== undefined && origin !== null && origin !== '') {
@@ -335,7 +347,12 @@ export function admitKeySetupRequest({
try {
parsedOrigin = new URL(String(origin));
} catch {
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
return {
ok: false,
status: 403,
error: 'Unrecognized Origin refused',
code: 'origin-unrecognized',
};
}
const exactOrigin =
parsedOrigin.username === '' &&
@@ -349,6 +366,7 @@ export function admitKeySetupRequest({
ok: false,
status: 403,
error: 'Cross-origin requests are refused',
code: 'cross-origin',
};
}
}
@@ -362,6 +380,7 @@ export function admitKeySetupRequest({
ok: false,
status: 415,
error: 'Content-Type must be application/json',
code: 'content-type',
};
}
return { ok: true };
@@ -376,7 +395,19 @@ export function knownKeySetupEnvVars() {
return names;
}
/** Tooltip guidance for a control gated by one registry entry. */
/**
* Env-var names a registry entry needs, joined with ' + ', for callers that
* build the guidance sentence in the UI language (src/ui/keyGuidance.js).
* @param {string} id Registry entry id.
* @returns {string|null} null for an unknown or hidden entry.
*/
export function keySetupRequirementEnvVars(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry || entry.hidden) return null;
return entry.envVars.join(' + ');
}
/** English tooltip guidance for a control gated by one registry entry. */
export function keySetupRequirement(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
if (!entry || entry.hidden) return '';
@@ -458,6 +489,8 @@ export function keySetupStatus(env = {}) {
* below safe to write without quoting rules. A `null` value means REMOVE:
* the writer comments the assignment back out, returning the file to its
* template state for that key.
* A refusal carries `code` and `params` for the panel's localized sentence
* (keySetup.js) besides the English `error`.
* @param {unknown} body Parsed JSON from the request.
*/
export function validateKeySetupUpdates(body) {
@@ -465,38 +498,63 @@ export function validateKeySetupUpdates(body) {
return {
ok: false,
error: 'Body must be a JSON object of {ENV_VAR: value}',
code: 'not-object',
};
}
const entries = Object.entries(body);
if (entries.length === 0) return { ok: false, error: 'No keys provided' };
if (entries.length === 0)
return { ok: false, error: 'No keys provided', code: 'no-keys' };
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
return {
ok: false,
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
code: 'too-many',
params: { limit: KEY_SETUP_UPDATE_LIMIT },
};
}
const known = knownKeySetupEnvVars();
const updates = {};
for (const [name, raw] of entries) {
if (!known.has(name)) return { ok: false, error: `Unknown key: ${name}` };
if (!known.has(name))
return {
ok: false,
error: `Unknown key: ${name}`,
code: 'unknown-key',
params: { name },
};
if (raw === null) {
updates[name] = null;
continue;
}
if (typeof raw !== 'string')
return { ok: false, error: `${name} must be a string` };
return {
ok: false,
error: `${name} must be a string`,
code: 'not-string',
params: { name },
};
const value = raw.trim();
if (!value) return { ok: false, error: `${name} is empty` };
if (!value)
return {
ok: false,
error: `${name} is empty`,
code: 'empty',
params: { name },
};
if (value.length > KEY_SETUP_VALUE_LIMIT) {
return {
ok: false,
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
code: 'too-long',
params: { name, limit: KEY_SETUP_VALUE_LIMIT },
};
}
if (!/^[\x21-\x7e]+$/.test(value)) {
return {
ok: false,
error: `${name} may only contain printable characters with no spaces`,
code: 'not-printable',
params: { name },
};
}
// Reject the dotenv metacharacters that would round-trip WRONG when written
@@ -508,6 +566,8 @@ export function validateKeySetupUpdates(body) {
return {
ok: false,
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
code: 'invalid-char',
params: { name },
};
}
updates[name] = value;
+132
View File
@@ -0,0 +1,132 @@
// A refused key save reads in the panel's language: the server sends `code`
// and `params` beside its English `error`, the panel builds the sentence.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
admitKeySetupRequest,
KEY_SETUP_UPDATE_LIMIT,
validateKeySetupUpdates,
} from './keySetupCore.mjs';
import { collectKeyUpdates, saveRefusalText } from './keySetup.js';
import { setLocale } from './i18n/index.js';
import { extractMessages } from '../scripts/i18n.mjs';
const local = {
method: 'POST',
remoteAddress: '127.0.0.1',
hostHeader: 'localhost:4173',
origin: 'http://localhost:4173',
contentType: 'application/json',
};
// Every refusal the core can return, plus the ones only the endpoint sends
// (server/standalone/key-setup.js), with the English the server writes.
const refusals = () => [
validateKeySetupUpdates(
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: '"sk-abc123"' }]),
),
validateKeySetupUpdates(
collectKeyUpdates([{ envVar: 'OPENAI_API_KEY', value: 'sk abc123' }]),
),
validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(600) }),
validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }),
validateKeySetupUpdates({ OPENAI_API_KEY: 42 }),
validateKeySetupUpdates({ PATH: '/usr/bin' }),
validateKeySetupUpdates({}),
validateKeySetupUpdates([]),
validateKeySetupUpdates(
Object.fromEntries(
Array.from({ length: KEY_SETUP_UPDATE_LIMIT + 1 }, (_, i) => [
`K${i}`,
'v',
]),
),
),
admitKeySetupRequest({ ...local, proxyHeaders: { 'x-forwarded-for': '1' } }),
admitKeySetupRequest({
...local,
env: { PINOKIO_SHARE_CLOUDFLARE: 'true' },
}),
admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }),
admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }),
admitKeySetupRequest({ ...local, origin: undefined }),
admitKeySetupRequest({ ...local, origin: 'not a url' }),
admitKeySetupRequest({ ...local, origin: 'https://evil.example' }),
admitKeySetupRequest({ ...local, contentType: 'text/plain' }),
{ error: 'Method not allowed', code: 'method' },
{ error: 'Request too large', code: 'too-large' },
{ error: 'Invalid JSON', code: 'invalid-json' },
{
error:
'OPENAI_API_KEY is configured outside Provider Settings and can only be changed where it was set',
code: 'external',
params: { name: 'OPENAI_API_KEY' },
},
{
error:
'The key was not saved: could not restrict the credential file to your account; nothing was saved',
code: 'harden-failed',
},
{
error:
'The key was not saved: the existing configuration could not be read, so nothing was changed',
code: 'store-unreadable',
},
{
error: 'Could not write the env-file store',
code: 'write-failed',
params: { store: 'env-file' },
},
];
test('every refusal carries a code', () => {
for (const refusal of refusals()) {
assert.equal(refusal.ok ?? false, false);
assert.equal(typeof refusal.code, 'string', refusal.error);
}
});
test('in English the panel shows exactly the server sentence', () => {
setLocale('en');
for (const refusal of refusals())
assert.equal(saveRefusalText(refusal, 400), refusal.error);
});
test('an unknown code or a bare error falls back to the server text or the status', () => {
setLocale('en');
assert.equal(
saveRefusalText({ error: 'Something new', code: 'new' }, 400),
'Something new',
);
assert.equal(saveRefusalText({}, 502), 'Save failed (502).');
});
test('the refusal sentences are catalog messages', () => {
const messages = extractMessages();
setLocale('en');
for (const refusal of refusals()) {
const pattern = saveRefusalText(
{
code: refusal.code,
params: { name: '{name}', limit: '{limit}', store: '{store}' },
},
400,
);
assert.ok(messages.has(pattern), pattern);
}
});
for (const locale of ['es', 'ru']) {
test(`${locale}: a refused save reads in the panel language`, () => {
setLocale(locale);
try {
for (const refusal of refusals()) {
const text = saveRefusalText(refusal, 400);
assert.notEqual(text, refusal.error, `${locale}: ${refusal.error}`);
if (refusal.params?.name) assert.ok(text.includes(refusal.params.name));
}
} finally {
setLocale('en');
}
});
}
+53
View File
@@ -0,0 +1,53 @@
// A failed ChatGPT OAuth check whose only reason is our own fallback sentence
// shows it alone; a server-sent reason keeps the «OAuth check failed» wrapper.
import test from 'node:test';
import assert from 'node:assert/strict';
import { oauthCheckFailureText, waitForChatGptOAuth } from './keySetup.js';
import { setLocale } from './i18n/index.js';
const failWith = (payload, ok = true) =>
waitForChatGptOAuth({
fetchImpl: async () => ({ ok, json: async () => payload }),
sleep: () => assert.fail('failed login must stop polling'),
}).then(
() => assert.fail('expected a rejection'),
(error) => error,
);
test('a fallback-only OAuth failure is shown without the wrapper in every locale', async (t) => {
t.after(() => setLocale('en'));
const expected = {
en: 'Could not check ChatGPT sign-in. Try again.',
ru: 'Не удалось проверить вход в ChatGPT. Попробуйте ещё раз.',
es: 'No se pudo comprobar el inicio de sesión de ChatGPT. Inténtalo de nuevo.',
};
for (const [locale, text] of Object.entries(expected)) {
setLocale(locale);
const loginFailed = await failWith({ available: false, loginFailed: true });
assert.equal(oauthCheckFailureText(loginFailed), text);
const httpFailed = await failWith({}, false);
assert.equal(oauthCheckFailureText(httpFailed), text);
}
});
test('a server-sent or network OAuth failure keeps the «OAuth check failed» wrapper', async (t) => {
t.after(() => setLocale('en'));
setLocale('en');
const server = await failWith({
loginFailed: true,
error: 'Sign-in is local only.',
});
assert.equal(
oauthCheckFailureText(server),
'OAuth check failed: Sign-in is local only.',
);
assert.equal(
oauthCheckFailureText(new TypeError('Failed to fetch')),
'OAuth check failed: Failed to fetch',
);
setLocale('ru');
assert.match(
oauthCheckFailureText(server),
/^Не удалось проверить OAuth: Sign-in is local only\.$/,
);
});
+66
View File
@@ -0,0 +1,66 @@
// Every status line of the ChatGPT OAuth flow in Provider Settings goes
// through t(), and the i18n check treats say() as a place UI text lands.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
extractMessages,
readCatalog,
shippedLocales,
unmarkedScript,
} from '../scripts/i18n.mjs';
// Status-line texts of the ChatGPT OAuth flow in Provider Settings
// (src/keySetup.js, handler of [data-cloud-voice-auth-toggle]).
const OAUTH_STATUS_TEXTS = [
'Cloud voice will use OPENAI_API_KEY on the next session.',
'Checking local ChatGPT OAuth sign-in…',
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
'ChatGPT sign-in expired. Opening sign-in again…',
'Opening ChatGPT sign-in in your browser…',
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
'ChatGPT sign-in timed out. Click {button} to try again.',
'OAuth check failed: {detail}',
'Could not check ChatGPT sign-in.',
'Could not check ChatGPT sign-in. Try again.',
'Could not start ChatGPT sign-in on this machine.',
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
];
test('OAuth status-line texts are extractable messages with es/ru entries', () => {
const messages = extractMessages();
const missing = OAUTH_STATUS_TEXTS.filter((text) => !messages.has(text));
assert.deepEqual(missing, [], 'texts not wrapped in t() in src/keySetup.js');
for (const locale of shippedLocales()) {
const catalog = readCatalog(locale);
const untranslated = OAUTH_STATUS_TEXTS.filter((text) => !catalog[text]);
assert.deepEqual(untranslated, [], `${locale}.json lacks entries`);
}
});
test('the i18n check sees an unmarked literal passed to say()', () => {
const sample =
"const say = (x) => { el.textContent = x; };\nsay('Checking local ChatGPT OAuth sign-in…');\n";
const found = unmarkedScript('sample.js', sample);
assert.ok(
found.some((f) => f.text.includes('Checking local ChatGPT OAuth')),
'say(...) is not among JS_SINKS in scripts/i18n.mjs',
);
});
test('src/keySetup.js has no unmarked say() literals', () => {
const source = readFileSync(
new URL('./keySetup.js', import.meta.url),
'utf8',
);
const literal = /\bsay\(\s*['"`]/g;
const hits = [...source.matchAll(literal)].map(
(m) => source.slice(0, m.index).split('\n').length,
);
assert.deepEqual(
hits,
[],
`say() called with a bare literal at lines ${hits.join(', ')}`,
);
});
@@ -0,0 +1,107 @@
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
// card (title, coverage, status, legend, risk) and its own failure text.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { translateFeedError } from '../../ui/statusText.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
function harness(source) {
const layer = createAirQualityLayer({
source,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
const viewer = { dataSources: { add() {}, remove() {} } };
layer.init(viewer);
layer.enable(viewer);
return layer;
}
const reading = {
id: 'eccc:A',
provider: 'eccc',
scale: 'AQHI',
value: 2,
band: 'low',
stationId: 'A',
name: 'Toronto',
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: '2',
risk: 'Low health risk',
note: null,
};
for (const locale of ['es', 'ru']) {
test(`${locale}: the layer name is translated`, () => {
inLocale(locale, () => {
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
assert.equal(layerNameForId('air-quality'), t('Air quality'));
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
});
});
test(`${locale}: the Weather card of Air quality is translated`, async () => {
const layer = harness({ getSnapshot: async () => [reading] });
await layer.update();
const controls = inLocale(locale, () => layer.getRowControls());
const english = [
controls.summary.label,
controls.summary.coverage,
controls.summary.status,
...controls.legend.map((entry) => entry.label),
...controls.list.items.map((item) => item.text),
].filter((text) =>
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
text,
),
);
assert.deepEqual(english, [], `English left in the ${locale} card`);
});
test(`${locale}: the Air quality failure text is translated in the row`, () => {
const row = inLocale(locale, () =>
LayerPanel.prototype._buildMetaText({
id: 'air-quality',
enabled: true,
stats: {
count: 0,
error: 'Air quality request failed',
source: 'ECCC',
lastUpdate: null,
},
}),
);
assert.ok(!row.includes('Air quality request failed'), row);
});
}
for (const locale of ['en', 'es', 'ru']) {
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
const layer = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
const card = inLocale(locale, () => layer.getRowControls().summary.status);
assert.equal(
card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
);
});
}
+19 -10
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
@@ -109,7 +111,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || 'Air quality request failed';
_lastError = error?.message || msg('Air quality request failed');
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
@@ -214,34 +216,41 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
// Readings and errors stay English data (analyst records, stats); the
// card translates them as the Layers panel row does. A provider's own
// error text has no catalog entry and shows as it came.
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
label: t(provider.label),
coverage: t(provider.coverage),
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
? translateFeedError(_lastError)
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
? t('No readings available')
: t('No current readings')
: tn(
_count,
'{count} station reporting',
'{count} stations reporting',
),
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
label: t(band.label),
})),
legendCategorical: true,
list: {
ariaLabel: 'Highest air-quality readings in view',
ariaLabel: t('Highest air-quality readings in view'),
items: inView.map((reading, index) => ({
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk}`,
text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color,
})),
},
+30 -8
View File
@@ -15,13 +15,36 @@
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
// A portable export (package.json): no i18n here. The labels stay English data;
// the Weather card (index.js) shows them translated.
const msg = (text) => text;
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
Object.freeze({
id: 'low',
label: msg('Low'),
risk: msg('Low health risk'),
color: '#4cc9f0',
}),
Object.freeze({
id: 'moderate',
label: msg('Moderate'),
risk: msg('Moderate health risk'),
color: '#ffd43b',
}),
Object.freeze({
id: 'high',
label: msg('High'),
risk: msg('High health risk'),
color: '#ff6b35',
}),
Object.freeze({
id: 'very-high',
label: msg('Very High'),
risk: msg('Very High health risk'),
color: '#c1121f',
}),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
@@ -89,9 +112,9 @@ export const AIR_QUALITY_PROVIDERS = Object.freeze({
eccc: Object.freeze({
id: 'eccc',
scale: 'AQHI',
label: 'Air quality',
label: msg('Air quality'),
network: 'Environment and Climate Change Canada',
coverage: 'Canada only · ECCC AQHI network',
coverage: msg('Canada only · ECCC AQHI network'),
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
@@ -161,8 +184,7 @@ export function valueLabel(providerId, value) {
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
return airQualityBandById(bandId)?.risk ?? null;
}
/**
+10 -4
View File
@@ -1,6 +1,10 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel and the Weather card show them translated.
const msg = (text) => text;
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('ECCC request timed out')),
() => controller.abort(new Error(msg('ECCC request timed out'))),
timeoutMs,
);
try {
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
signal,
);
const next = normalizeAqhiStations(payload);
if (!next) throw new Error('Malformed AQHI station response');
if (!next) throw new Error(msg('Malformed AQHI station response'));
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0) throw new Error('No AQHI stations available');
if (stations.size === 0)
throw new Error(msg('No AQHI stations available'));
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings) throw new Error('Malformed AQHI observation response');
if (!readings)
throw new Error(msg('Malformed AQHI observation response'));
return readings;
},
};
+34 -20
View File
@@ -18,6 +18,7 @@ import {
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
import { t, tn, msg } from '../../i18n/index.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
@@ -275,7 +276,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.stale = Boolean(state.lastUpdate);
setAlprStatus(
'unavailable',
error?.message || 'Camera source temporarily unavailable',
error?.message || msg('Camera source temporarily unavailable'),
);
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
id: LAYER_ID,
name: 'ALPR Cameras',
icon: '📷',
source: source.label || 'Mapped camera locations',
// English in layer state; the layer row translates it.
source: source.label || msg('Mapped camera locations'),
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
label: t('SHOW NEAREST'),
title: state.viewer?.trackedEntity
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
? t(
'Stop following the current object before navigating to a camera',
)
: t('Move to the nearest loaded camera and show its details'),
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
legend: [
{
label: 'Camera badges',
label: t('Camera badges'),
color: ALPR_COLOR,
count,
blurb:
blurb: t(
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
),
},
],
};
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
count: state.dataSource?.entities.values.length || 0,
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
}`
? Number.isInteger(state.onScreen)
? tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby · {onScreen} on screen',
'{count} nearby · {onScreen} on screen',
{ onScreen: state.onScreen },
)
: tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby',
'{count} nearby',
)
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
: 0,
loadingLabel: state.loading
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
? t('retrying mapped ALPR cameras')
: t('loading mapped ALPR cameras')
: state.noCoverage
? 'No ALPR data for this area — US and Canada only'
? t('No ALPR data for this area — US and Canada only')
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
? t('Zoom in to load mapped cameras')
: [
state.stale ? 'Showing cached locations' : '',
state.stale ? t('Showing cached locations') : '',
state.saturated || state.renderSaturated
? 'Coverage limited — zoom in'
? t('Coverage limited — zoom in')
: '',
state.status === 'empty'
? t('No ALPR data for this area')
: '',
state.status === 'empty' ? 'No ALPR data for this area' : '',
state.status === 'ready' && state.onScreen === 0
? 'None on screen — nearby cameras are outside the view'
? t('None on screen — nearby cameras are outside the view')
: '',
]
.filter(Boolean)
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
label: t('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+7 -4
View File
@@ -16,6 +16,9 @@ import {
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
@@ -136,7 +139,7 @@ export function createAlprTileSource({
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error('Camera coverage unavailable');
throw new Error(msg('Camera coverage unavailable'));
if (
box.east < west ||
box.west > east ||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
for (const source of sources) source.clear();
precision.clear();
},
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
@@ -242,7 +245,7 @@ export function createOverpassAlprSource({
// The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) {
throw new Error('Overpass returned an incomplete camera response');
throw new Error(msg('Overpass returned an incomplete camera response'));
}
return {
records: [
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
}
return {
fetch: fetchAlprNodes,
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
+43 -15
View File
@@ -1,8 +1,9 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
@@ -14,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
: count;
}
/** The count as the panel shows it, in the UI language. */
function presentAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = formatNumber(summary.count);
return summary.complete === false || summary.truncated === true
? t('At least {count}', { count })
: count;
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** The cohort's reason, kept English in data, shown in the UI language. */
function presentAwarenessReason(summary = {}) {
return summary.reasonTemplate
? t(summary.reasonTemplate, summary.reasonValues)
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -56,8 +84,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
@@ -70,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = formatAwarenessCount(summary);
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
@@ -78,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="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 === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -95,17 +123,17 @@ export function createPanel({ state: layerState, services, parts, source }) {
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
<button type="button" data-awareness-action="focus">FOCUS</button>
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
}
@@ -175,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function namedAreasHtml(names) {
return names?.length
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
}
@@ -183,11 +211,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
+25 -12
View File
@@ -1,4 +1,16 @@
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`.
const msg = (text) => text;
const fillTemplate = (template, values = {}) =>
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
const withReason = (summary, template, values) => ({
...summary,
reason: fillTemplate(template, values),
reasonTemplate: template,
reasonValues: values,
});
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
@@ -119,19 +131,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
// A subject window is its own bounded area: name it instead of the viewport.
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
const km = Math.round(coverage.radiusM / 1000);
return {
...summary,
reason: summary.count
? `mapped matches within ${km} km of the subject`
: `none mapped within ${km} km; not a complete 250 km survey`,
};
return withReason(
summary,
summary.count
? msg('mapped matches within {km} km of the subject')
: msg('none mapped within {km} km; not a complete 250 km survey'),
{ km },
);
}
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
return withReason(
summary,
summary.count
? msg('mapped matches from the loaded viewport')
: msg('viewport feed is not a complete 250 km survey'),
);
}
/**
+11 -4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -137,7 +138,9 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
@@ -221,10 +224,14 @@ export function createRendering({
)
: null;
const bearingText = Number.isFinite(bearing)
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
: 'BRG —';
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
+20 -5
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
@@ -10,6 +11,19 @@ import {
AWARENESS_PAGE_SIZE,
} from './policy.js';
// coverageLabel is machine-composed English from installations/controls.js
// (pinned by src/data/militaryInstallations.test.mjs); translate the known
// forms here, pass anything else through.
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('WITHIN {km} KM', { km: within[1] });
return label;
}
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
@@ -78,7 +92,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [
{
id: 'flights',
label: 'Flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
@@ -97,7 +111,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military',
label: 'Military flights',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
@@ -116,7 +130,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
@@ -131,7 +145,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: 'Mapped installations',
label: t('Mapped installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
@@ -142,7 +156,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
SOURCE_LABEL['military-installations'],
),
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
+6 -3
View File
@@ -1,3 +1,4 @@
import { msg, t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -67,8 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
? t('syncing {count} city feeds...', {
count: layerState._activeCityIds.size,
})
: t('scanning nearby systems...');
}
// A failed city is only the layer's failure when it left nothing on the
// map. While other cities still render, the partial failure reads
@@ -76,7 +79,7 @@ export function createControls({ state: layerState, services, parts, source }) {
// points remain, they read STALE.
if (layerState._failedCityIds.size > 0) {
if (layerState._stationRenderMap.size === 0) {
stats.error = 'GBFS fetch error';
stats.error = msg('GBFS fetch error');
} else {
stats.degraded = true;
const allFailed = Array.from(layerState._activeCityIds).every(
+12 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
@@ -22,7 +23,9 @@ export function createSelection({
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
(record?.stationId
? t('Station {id}', { id: record.stationId })
: t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -33,14 +36,18 @@ export function createSelection({
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
t('🚲 {bikes} avail · {docks} docks · {capacity} cap', {
bikes,
docks,
capacity,
}),
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff. Show more