mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge pull request #602 from meirm/i18n-spanish
i18n: interface localization with Spanish and Russian (English-text keys)
This commit is contained in:
407 files changed
+29542
-4338
No files matched your search
@@ -170,6 +170,18 @@
|
|||||||
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
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.
|
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
|
- Street Level counts unique tiles before ranking so overlapping dateline halves
|
||||||
in polar views do not waste slots in the 16-tile overview budget.
|
in polar views do not waste slots in the 16-tile overview budget.
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ The highest-leverage places to jump in:
|
|||||||
- ES modules, **2-space indent, single quotes, semicolons.**
|
- ES modules, **2-space indent, single quotes, semicolons.**
|
||||||
- JSDoc on exported/public functions.
|
- JSDoc on exported/public functions.
|
||||||
- Match the surrounding code — comment density, naming, and idiom.
|
- 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.
|
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||||
|
|
||||||
## Formatting and reusable components
|
## Formatting and reusable components
|
||||||
|
|||||||
@@ -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`.
|
> 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.
|
> 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
|
## Canonical Docs Order
|
||||||
|
|
||||||
Use docs in this order when details conflict:
|
Use docs in this order when details conflict:
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -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.
|
||||||
@@ -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 … |
|
||||||
@@ -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) | Л / П |
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"doctor": "node scripts/setup-doctor.mjs",
|
"doctor": "node scripts/setup-doctor.mjs",
|
||||||
"format": "node scripts/format.mjs --write",
|
"format": "node scripts/format.mjs --write",
|
||||||
"format:check": "node scripts/format.mjs --check",
|
"format:check": "node scripts/format.mjs --check",
|
||||||
|
"i18n": "node scripts/i18n.mjs",
|
||||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
+352
-53
File diff suppressed because it is too large.
Load diff
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
|
|||||||
for (let i = 0; i < 20; i += 1) {
|
for (let i = 0; i < 20; i += 1) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
|
// The label is translated; the machine state is on the control.
|
||||||
if (status && status !== 'CONNECTING') break;
|
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
|
||||||
|
if (status && status !== 'connecting') break;
|
||||||
}
|
}
|
||||||
let diag = null;
|
let diag = null;
|
||||||
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
|
|||||||
const GIS =
|
const GIS =
|
||||||
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
||||||
const HOUR = 3600_000;
|
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 =
|
const COVERAGE =
|
||||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||||
const LAYERS = [
|
const LAYERS = [
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js';
|
|||||||
import { decodeWindGribMessage } from './wind/decode.js';
|
import { decodeWindGribMessage } from './wind/decode.js';
|
||||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.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. */
|
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
|
||||||
export function windProxy({
|
export function windProxy({
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
@@ -25,7 +29,7 @@ export function windProxy({
|
|||||||
...(overlay === 'none' ? {} : { overlay }),
|
...(overlay === 'none' ? {} : { overlay }),
|
||||||
unavailable: true,
|
unavailable: true,
|
||||||
stale: true,
|
stale: true,
|
||||||
reason: 'Wind upstream unavailable',
|
reason: UPSTREAM_UNAVAILABLE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const sendJson = (res, value, status = 200) => {
|
const sendJson = (res, value, status = 200) => {
|
||||||
@@ -139,7 +143,7 @@ export function windProxy({
|
|||||||
old.manifest = {
|
old.manifest = {
|
||||||
...old.manifest,
|
...old.manifest,
|
||||||
stale: true,
|
stale: true,
|
||||||
reason: 'Wind upstream unavailable',
|
reason: UPSTREAM_UNAVAILABLE,
|
||||||
};
|
};
|
||||||
return old;
|
return old;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
|||||||
};
|
};
|
||||||
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
// The gate itself is pure and unit-tested (admitKeySetupRequest in
|
||||||
// src/keySetupCore.mjs) — this just feeds it the request.
|
// 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) =>
|
const admit = (req) =>
|
||||||
admitKeySetupRequest({
|
admitKeySetupRequest({
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -247,18 +249,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
|||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use('/api/setup/status', (req, res) => {
|
server.middlewares.use('/api/setup/status', (req, res) => {
|
||||||
if (req.method !== 'GET')
|
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);
|
const admission = admit(req);
|
||||||
if (!admission.ok)
|
if (!admission.ok)
|
||||||
return respond(res, admission.status, { error: admission.error });
|
return respond(res, admission.status, refusal(admission));
|
||||||
respond(res, 200, providerStatus());
|
respond(res, 200, providerStatus());
|
||||||
});
|
});
|
||||||
server.middlewares.use('/api/setup/keys', (req, res) => {
|
server.middlewares.use('/api/setup/keys', (req, res) => {
|
||||||
if (req.method !== 'POST')
|
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);
|
const admission = admit(req);
|
||||||
if (!admission.ok)
|
if (!admission.ok)
|
||||||
return respond(res, admission.status, { error: admission.error });
|
return respond(res, admission.status, refusal(admission));
|
||||||
let body = '';
|
let body = '';
|
||||||
let overflowed = false;
|
let overflowed = false;
|
||||||
req.on('data', (chunk) => {
|
req.on('data', (chunk) => {
|
||||||
@@ -270,15 +278,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
|||||||
});
|
});
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
if (overflowed)
|
if (overflowed)
|
||||||
return respond(res, 413, { error: 'Request too large' });
|
return respond(res, 413, {
|
||||||
|
error: 'Request too large',
|
||||||
|
code: 'too-large',
|
||||||
|
});
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(body || '{}');
|
parsed = JSON.parse(body || '{}');
|
||||||
} catch {
|
} catch {
|
||||||
return respond(res, 400, { error: 'Invalid JSON' });
|
return respond(res, 400, {
|
||||||
|
error: 'Invalid JSON',
|
||||||
|
code: 'invalid-json',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const verdict = validateKeySetupUpdates(parsed);
|
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
|
// Neither a replace NOR a removal may touch an externally-supplied
|
||||||
// credential (shell env, Keychain, another workflow). This backs the
|
// credential (shell env, Keychain, another workflow). This backs the
|
||||||
// UI's read-only "configured externally" state with a real contract —
|
// UI's read-only "configured externally" state with a real contract —
|
||||||
@@ -289,6 +303,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
|
|||||||
if (isExternallyManaged(name, inStore)) {
|
if (isExternallyManaged(name, inStore)) {
|
||||||
return respond(res, 409, {
|
return respond(res, 409, {
|
||||||
error: `${name} is configured outside Provider Settings and can only be changed where it was set`,
|
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, {
|
return respond(res, 500, {
|
||||||
error: `The key was not saved: ${error.message}`,
|
error: `The key was not saved: ${error.message}`,
|
||||||
|
code:
|
||||||
|
error.code === 'GEV_HARDEN_FAILED'
|
||||||
|
? 'harden-failed'
|
||||||
|
: 'store-unreadable',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return respond(res, 500, {
|
return respond(res, 500, {
|
||||||
error: `Could not write the ${storeName()} store`,
|
error: `Could not write the ${storeName()} store`,
|
||||||
|
code: 'write-failed',
|
||||||
|
params: { store: storeName() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Live for the server-side proxies immediately; the restart below is
|
// Live for the server-side proxies immediately; the restart below is
|
||||||
|
|||||||
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'highlight',
|
type: 'highlight',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'Palace of Fine Arts',
|
label: 'Palace of Fine Arts',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'area',
|
type: 'area',
|
||||||
target: 'Presidio of San Francisco',
|
target: 'Presidio of San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'The Presidio (former Army base)',
|
label: 'The Presidio (former Army base)',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
footprint: true,
|
footprint: true,
|
||||||
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'pin',
|
type: 'pin',
|
||||||
target: 'Letterman Digital Arts Center, San Francisco',
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'ILM / Lucasfilm',
|
label: 'ILM / Lucasfilm',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
|
|||||||
type: 'arrow',
|
type: 'arrow',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
toTarget: 'Marina District, San Francisco',
|
toTarget: 'Marina District, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'next to the Marina',
|
label: 'next to the Marina',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'highlight',
|
type: 'highlight',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'Palace of Fine Arts',
|
label: 'Palace of Fine Arts',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
},
|
},
|
||||||
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
|
|||||||
type: 'arrow',
|
type: 'arrow',
|
||||||
target: 'Palace of Fine Arts, San Francisco',
|
target: 'Palace of Fine Arts, San Francisco',
|
||||||
toTarget: 'Marina Green, San Francisco',
|
toTarget: 'Marina Green, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'next to the Marina',
|
label: 'next to the Marina',
|
||||||
color: 'cyan',
|
color: 'cyan',
|
||||||
},
|
},
|
||||||
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'area',
|
type: 'area',
|
||||||
target: 'Presidio of San Francisco',
|
target: 'Presidio of San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'The Presidio — a former Army base',
|
label: 'The Presidio — a former Army base',
|
||||||
color: 'green',
|
color: 'green',
|
||||||
footprint: true,
|
footprint: true,
|
||||||
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'pin',
|
type: 'pin',
|
||||||
target: 'Letterman Digital Arts Center, San Francisco',
|
target: 'Letterman Digital Arts Center, San Francisco',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'ILM / Lucasfilm',
|
label: 'ILM / Lucasfilm',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
|
|||||||
{
|
{
|
||||||
type: 'route',
|
type: 'route',
|
||||||
color: 'amber',
|
color: 'amber',
|
||||||
|
// i18n-ignore: dev demo of voice-agent labels (not translated)
|
||||||
label: 'Crissy Field shoreline',
|
label: 'Crissy Field shoreline',
|
||||||
points: [
|
points: [
|
||||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { t, tn } from '../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual whiteboard drawing: the pure half.
|
* Manual whiteboard drawing: the pure half.
|
||||||
*
|
*
|
||||||
@@ -284,20 +286,26 @@ export function closeRing(pairs) {
|
|||||||
|
|
||||||
/** One line of guidance for the person drawing, by state. */
|
/** One line of guidance for the person drawing, by state. */
|
||||||
export function drawHint(session) {
|
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;
|
const n = session.vertices.length;
|
||||||
if (session.shape === 'pin')
|
if (session.shape === 'pin')
|
||||||
return n
|
return n
|
||||||
? 'Enter to place the pin, Esc to cancel.'
|
? t('Enter to place the pin, Esc to cancel.')
|
||||||
: 'Click where the pin goes.';
|
: t('Click where the pin goes.');
|
||||||
const need = MIN_VERTICES[session.shape] - n;
|
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') {
|
if (finishReason(session) === 'degenerate') {
|
||||||
return session.shape === 'area'
|
return session.shape === 'area'
|
||||||
? 'Those points are in a line — move one off it to enclose an area.'
|
? t('Those points are in a line — move one off it to enclose an area.')
|
||||||
: 'That line has no length — click somewhere further away.';
|
: t('That line has no length — click somewhere further away.');
|
||||||
}
|
}
|
||||||
const full =
|
const full =
|
||||||
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
|
n >= MAX_VERTICES
|
||||||
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
|
? 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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
pointerOwner,
|
pointerOwner,
|
||||||
releasePointer,
|
releasePointer,
|
||||||
} from '../data/inputOwnership.js';
|
} from '../data/inputOwnership.js';
|
||||||
|
import { pointerOwnerName } from '../data/pointerOwnerNames.js';
|
||||||
import {
|
import {
|
||||||
DRAW_SHAPES,
|
DRAW_SHAPES,
|
||||||
MAX_VERTICES,
|
MAX_VERTICES,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
removeLastVertex,
|
removeLastVertex,
|
||||||
} from './drawMode.js';
|
} from './drawMode.js';
|
||||||
|
|
||||||
|
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
|
||||||
/** The id this tool claims the pointer under. */
|
/** The id this tool claims the pointer under. */
|
||||||
export const DRAW_POINTER_OWNER = 'draw';
|
export const DRAW_POINTER_OWNER = 'draw';
|
||||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||||
@@ -100,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
target.addEventListener(type, listener, options);
|
target.addEventListener(type, listener, options);
|
||||||
domListeners.push([target, 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);
|
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
|
||||||
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
|
// `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.
|
// a limit rather than the harmless tail of a double-click.
|
||||||
if (reason === 'full')
|
if (reason === 'full')
|
||||||
setHint(
|
setHint(
|
||||||
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
|
t(
|
||||||
|
'That shape already has {count} points — finish it or press Backspace.',
|
||||||
|
{ count: MAX_VERTICES },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
else if (reason === 'invalid')
|
else if (reason === 'invalid')
|
||||||
setHint('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) => {
|
const onMove = (event) => {
|
||||||
if (!session || session.shape === 'pin' || destroyed) return;
|
if (!session || session.shape === 'pin' || destroyed) return;
|
||||||
@@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
flyTo: false,
|
flyTo: false,
|
||||||
});
|
});
|
||||||
if (destroyed || attempt !== generation) return result;
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (destroyed || attempt !== generation) return null;
|
if (destroyed || attempt !== generation) return null;
|
||||||
setHint(`Could not place the shape: ${error?.message || error}`);
|
setHint(
|
||||||
|
t('Could not place the shape: {error}', {
|
||||||
|
error: error?.message || error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
if (session) session = createDrawSession(shape);
|
if (session) session = createDrawSession(shape);
|
||||||
annotations.clear();
|
annotations.clear();
|
||||||
syncPreview();
|
syncPreview();
|
||||||
if (!destroyed && !active) setHint('Board cleared.');
|
if (!destroyed && !active) setHint(t('Board cleared.'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- keys: only while drawing, never while typing in another field ----
|
// ---- 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
|
// Somebody else is using the pointer — possibly an older instance of
|
||||||
// this same tool that has not finished tearing down. Say so instead of
|
// this same tool that has not finished tearing down. Say so instead of
|
||||||
// half-starting.
|
// half-starting.
|
||||||
setHint(`${pointerOwner()} is using the pointer — close it first.`);
|
setHint(
|
||||||
|
t('{owner} is using the pointer — close it first.', {
|
||||||
|
owner: translateKnown(pointerOwnerName(pointerOwner())),
|
||||||
|
}),
|
||||||
|
);
|
||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
generation += 1;
|
generation += 1;
|
||||||
if (active) setActive(false);
|
if (active) setActive(false);
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
unregisterUnsavedWork();
|
||||||
releaseSceneHandler();
|
releaseSceneHandler();
|
||||||
releasePointer(lease);
|
releasePointer(lease);
|
||||||
lease = null;
|
lease = null;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { initDrawTool } from './drawTool.js';
|
import { initDrawTool } from './drawTool.js';
|
||||||
|
import { describeUnsavedWork } from '../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
isPointerFree,
|
isPointerFree,
|
||||||
pointerOwner,
|
pointerOwner,
|
||||||
@@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
|||||||
|
|
||||||
/* ── the tests ─────────────────────────────────────────────────────────── */
|
/* ── 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 () => {
|
test('turning Draw on takes a lease and turning it off gives it back', async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
try {
|
try {
|
||||||
@@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
|||||||
h.element('draw-toggle').emit('click');
|
h.element('draw-toggle').emit('click');
|
||||||
assert.equal(h.tool.active, false, 'it must not half-start');
|
assert.equal(h.tool.active, false, 'it must not half-start');
|
||||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
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');
|
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||||
|
|
||||||
releasePointer(other);
|
releasePointer(other);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import {
|
|||||||
getActiveTrackedReadoutId,
|
getActiveTrackedReadoutId,
|
||||||
TRACKED_OVERLAY_SOURCE_ID,
|
TRACKED_OVERLAY_SOURCE_ID,
|
||||||
} from '../data/trackedReadout.js';
|
} from '../data/trackedReadout.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
|
/** A label whose detailed outline could not be resolved. */
|
||||||
|
function outlineUnavailableLabel(label) {
|
||||||
|
return t('{label} · Detailed outline unavailable', { label: label || '' });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||||
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.poly);
|
group.appendChild(parts.poly);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.path);
|
group.appendChild(parts.path);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
});
|
});
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
group.appendChild(parts.leader);
|
group.appendChild(parts.leader);
|
||||||
parts.label = makeCallout(
|
parts.label = makeCallout(
|
||||||
anno.outlineUnavailable
|
anno.outlineUnavailable
|
||||||
? `${anno.label || ''} · Detailed outline unavailable`
|
? outlineUnavailableLabel(anno.label)
|
||||||
: anno.label,
|
: anno.label,
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
|
|||||||
}
|
}
|
||||||
rec.anno = anno;
|
rec.anno = anno;
|
||||||
if (anno.outlineUnavailable && rec.parts.label) {
|
if (anno.outlineUnavailable && rec.parts.label) {
|
||||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||||
sizeCallout(rec.parts.label);
|
sizeCallout(rec.parts.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
-2
@@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js';
|
|||||||
import { StyleManager } from '../ui/composition.js';
|
import { StyleManager } from '../ui/composition.js';
|
||||||
import { flyToAustin } from '../camera.js';
|
import { flyToAustin } from '../camera.js';
|
||||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** Construct the existing controls and camera presentation. */
|
/** Construct the existing controls and camera presentation. */
|
||||||
export function createApplicationControls({
|
export function createApplicationControls({
|
||||||
@@ -41,10 +42,10 @@ export function createApplicationControls({
|
|||||||
|
|
||||||
// If no share link state, do default fly-to Austin
|
// If no share link state, do default fly-to Austin
|
||||||
if (!styleManager.hasShareState) {
|
if (!styleManager.hasShareState) {
|
||||||
loaderStatus.textContent = 'Flying to Austin, TX...';
|
loaderStatus.textContent = t('Flying to Austin, TX...');
|
||||||
defer(flyToAustin(viewer));
|
defer(flyToAustin(viewer));
|
||||||
} else {
|
} else {
|
||||||
loaderStatus.textContent = 'Restoring shared view...';
|
loaderStatus.textContent = t('Restoring shared view...');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { styleManager, weatherEffects, cockpitCloudEffects };
|
return { styleManager, weatherEffects, cockpitCloudEffects };
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
|
|||||||
const notice = document.createElement('div');
|
const notice = document.createElement('div');
|
||||||
notice.id = 'status';
|
notice.id = 'status';
|
||||||
notice.textContent =
|
notice.textContent =
|
||||||
|
// i18n-ignore: host-panel script, no app catalogs
|
||||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||||
"View panels in this conversation hold them. Use Open in God's " +
|
"View panels in this conversation hold them. Use Open in God's " +
|
||||||
'Eye View above, or show it in a new conversation.';
|
'Eye View above, or show it in a new conversation.';
|
||||||
@@ -568,13 +569,16 @@ export function panelRuntime(config) {
|
|||||||
queued = view;
|
queued = view;
|
||||||
if (started) return;
|
if (started) return;
|
||||||
started = true;
|
started = true;
|
||||||
|
// i18n-ignore: host-panel script, no app catalogs
|
||||||
say("Loading God's Eye View…");
|
say("Loading God's Eye View…");
|
||||||
startApp(url, view).catch((error) =>
|
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}.`),
|
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!ready)
|
if (!ready)
|
||||||
say(
|
say(
|
||||||
|
// i18n-ignore: see above
|
||||||
"God's Eye View did not load here" +
|
"God's Eye View did not load here" +
|
||||||
(blocked ? ` (${blocked})` : '') +
|
(blocked ? ` (${blocked})` : '') +
|
||||||
". Use Open in God's Eye View above.",
|
". Use Open in God's Eye View above.",
|
||||||
@@ -616,6 +620,7 @@ export function panelRuntime(config) {
|
|||||||
if (context?.displayMode) displayMode = context.displayMode;
|
if (context?.displayMode) displayMode = context.displayMode;
|
||||||
if (context?.availableDisplayModes)
|
if (context?.availableDisplayModes)
|
||||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||||
|
// i18n-ignore: host-panel script, no app catalogs
|
||||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||||
};
|
};
|
||||||
expand.addEventListener('click', () => {
|
expand.addEventListener('click', () => {
|
||||||
@@ -640,6 +645,7 @@ export function panelRuntime(config) {
|
|||||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||||
appInfo: {
|
appInfo: {
|
||||||
name: 'gods-eye-view',
|
name: 'gods-eye-view',
|
||||||
|
// i18n-ignore: product name sent to the MCP host
|
||||||
title: "God's Eye View",
|
title: "God's Eye View",
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
},
|
},
|
||||||
@@ -655,6 +661,7 @@ export function panelRuntime(config) {
|
|||||||
},
|
},
|
||||||
(error) =>
|
(error) =>
|
||||||
say(
|
say(
|
||||||
|
// i18n-ignore: host-panel script, no app catalogs
|
||||||
"This client did not accept the God's Eye View panel" +
|
"This client did not accept the God's Eye View panel" +
|
||||||
(error?.message ? `: ${error.message}` : '.'),
|
(error?.message ? `: ${error.message}` : '.'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js';
|
|||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
import * as activation from '../../cctvFocusRequest.js';
|
import * as activation from '../../cctvFocusRequest.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.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 picking from '../../data/pickRegistry.js';
|
||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
@@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) {
|
|||||||
sprites,
|
sprites,
|
||||||
activation,
|
activation,
|
||||||
overlays,
|
overlays,
|
||||||
locations,
|
locations: { CITY_POIS },
|
||||||
picking,
|
picking,
|
||||||
terrain,
|
terrain,
|
||||||
ground,
|
ground,
|
||||||
|
|||||||
+11
-5
@@ -14,6 +14,7 @@ import {
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { describeError } from './errors.js';
|
import { describeError } from './errors.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** Construct the application globe using the caller's local configuration. */
|
/** Construct the application globe using the caller's local configuration. */
|
||||||
export async function createApplicationScene({
|
export async function createApplicationScene({
|
||||||
@@ -42,7 +43,7 @@ export async function createApplicationScene({
|
|||||||
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
|
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.
|
// Provider attribution stays visible, including clean-view and recording.
|
||||||
const creditContainer = document.createElement('div');
|
const creditContainer = document.createElement('div');
|
||||||
creditContainer.id = 'cesium-credits';
|
creditContainer.id = 'cesium-credits';
|
||||||
@@ -61,8 +62,8 @@ export async function createApplicationScene({
|
|||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
loaderStatus.textContent =
|
loaderStatus.textContent =
|
||||||
googleApiKey || googleTokens || cesiumToken
|
googleApiKey || googleTokens || cesiumToken
|
||||||
? 'Loading Google 3D Tiles...'
|
? t('Loading Google 3D Tiles...')
|
||||||
: 'Loading the keyless globe...';
|
: t('Loading the keyless globe...');
|
||||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
googleTokens,
|
googleTokens,
|
||||||
@@ -90,12 +91,17 @@ export async function createApplicationScene({
|
|||||||
tileError,
|
tileError,
|
||||||
);
|
);
|
||||||
const tileErrorDetail = describeError(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;
|
viewer.scene.globe.show = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
loaderStatus.textContent = 'Initializing systems...';
|
loaderStatus.textContent = t('Initializing systems...');
|
||||||
|
|
||||||
const mapStackController = new MapController(viewer, {
|
const mapStackController = new MapController(viewer, {
|
||||||
requestRender: governorRequestRender,
|
requestRender: governorRequestRender,
|
||||||
|
|||||||
+514
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
// say nothing about whether a destination is framed well.
|
// say nothing about whether a destination is framed well.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 DESTINATIONS = Object.entries(CITY_POIS);
|
||||||
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
|
const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading'];
|
||||||
|
|||||||
+647
-183
File diff suppressed because it is too large.
Load diff
+45
-11
@@ -83,31 +83,65 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
|||||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
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.
|
* HUD wording for a tracked aircraft's transponder emergency, or null.
|
||||||
* States what was broadcast, never a conclusion about the flight: the Mode A
|
* 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
|
* code when it names the status, else the ADS-B field it came from (an
|
||||||
* unrelated squawk is dropped).
|
* unrelated squawk is dropped).
|
||||||
* @param {{label?: string, squawk?: string|null, source?: string,
|
* The cockpit passes its own formatter so the page shows it in the active
|
||||||
* severity?: string}|null|undefined} emergency
|
* 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}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
export function formatCockpitEmergency(emergency) {
|
export function formatCockpitEmergency(emergency, format = ENGLISH_EMERGENCY) {
|
||||||
const label = String(emergency?.label || '')
|
const label = String(emergency?.label || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
|
const name = String(emergency.label).trim();
|
||||||
if (emergency.source === 'squawk' && emergency.squawk)
|
if (emergency.source === 'squawk' && emergency.squawk)
|
||||||
return `SQUAWK ${emergency.squawk} · ${label}`;
|
return format.squawk({ name, label, squawk: emergency.squawk });
|
||||||
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
|
return format.adsb({
|
||||||
return `ADS-B ${field} · ${label}`;
|
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(
|
export function formatCockpitContextScope(
|
||||||
subjectLabel,
|
subjectLabel,
|
||||||
radiusM,
|
radiusM,
|
||||||
installationCoverage = null,
|
installationCoverage = null,
|
||||||
|
format = ENGLISH_CONTEXT_SCOPE,
|
||||||
) {
|
) {
|
||||||
const normalizedLabel =
|
const normalizedLabel =
|
||||||
typeof subjectLabel === 'string'
|
typeof subjectLabel === 'string'
|
||||||
@@ -117,12 +151,12 @@ export function formatCockpitContextScope(
|
|||||||
: '—';
|
: '—';
|
||||||
const radiusKm =
|
const radiusKm =
|
||||||
Number.isFinite(radiusM) && radiusM >= 0
|
Number.isFinite(radiusM) && radiusM >= 0
|
||||||
? String(Math.round(radiusM / 1000))
|
? Math.round(radiusM / 1000)
|
||||||
: '—';
|
: null;
|
||||||
const coverage =
|
const coverage =
|
||||||
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
const base = format.window({ label: normalizedLabel, radiusKm });
|
||||||
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
|
return coverage ? format.installations({ scope: base, coverage }) : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return seven 30-degree compass divisions centered on a heading. */
|
/** Return seven 30-degree compass divisions centered on a heading. */
|
||||||
|
|||||||
+206
-53
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
|
|||||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||||
import { CockpitViewController } from './ui/cockpitController.js';
|
import { CockpitViewController } from './ui/cockpitController.js';
|
||||||
const previousDocument = globalThis.document;
|
const previousDocument = globalThis.document;
|
||||||
afterEach(() => { globalThis.document = previousDocument; });
|
afterEach(() => {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
});
|
||||||
|
|
||||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||||
// object reference alone is insufficient if reconciliation disconnects it.
|
// object reference alone is insufficient if reconciliation disconnects it.
|
||||||
@@ -22,23 +24,60 @@ function fixture() {
|
|||||||
this.className = '';
|
this.className = '';
|
||||||
this._text = '';
|
this._text = '';
|
||||||
this.classList = {
|
this.classList = {
|
||||||
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
add: (...names) => {
|
||||||
|
this.className = [
|
||||||
|
...new Set([
|
||||||
|
...this.className.split(/\s+/).filter(Boolean),
|
||||||
|
...names,
|
||||||
|
]),
|
||||||
|
].join(' ');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
|
get isConnected() {
|
||||||
get firstElementChild() { return this.children[0] || null; }
|
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||||
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
|
}
|
||||||
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
|
get firstElementChild() {
|
||||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
return this.children[0] || null;
|
||||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
}
|
||||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
get nextElementSibling() {
|
||||||
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
|
return (
|
||||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get textContent() {
|
||||||
|
return (
|
||||||
|
this._text + this.children.map((node) => node.textContent).join('')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set textContent(value) {
|
||||||
|
this.replaceChildren();
|
||||||
|
this._text = String(value);
|
||||||
|
}
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes.set(name, String(value));
|
||||||
|
}
|
||||||
|
getAttribute(name) {
|
||||||
|
return this.attributes.get(name) ?? null;
|
||||||
|
}
|
||||||
|
contains(node) {
|
||||||
|
return (
|
||||||
|
this === node || this.children.some((child) => child.contains(node))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
append(...nodes) {
|
||||||
|
for (const node of nodes) this.insertBefore(node, null);
|
||||||
|
}
|
||||||
insertBefore(node, anchor) {
|
insertBefore(node, anchor) {
|
||||||
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
|
assert.ok(
|
||||||
|
anchor === null || anchor.parentNode === this,
|
||||||
|
'insert anchor belongs to its parent',
|
||||||
|
);
|
||||||
if (node === anchor) return node;
|
if (node === anchor) return node;
|
||||||
node.remove();
|
node.remove();
|
||||||
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
|
const index =
|
||||||
|
anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||||
this.children.splice(index, 0, node);
|
this.children.splice(index, 0, node);
|
||||||
node.parentNode = this;
|
node.parentNode = this;
|
||||||
return node;
|
return node;
|
||||||
@@ -49,7 +88,10 @@ function fixture() {
|
|||||||
document.activeElement = document.body;
|
document.activeElement = document.body;
|
||||||
focusLosses += 1;
|
focusLosses += 1;
|
||||||
}
|
}
|
||||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
this.parentNode.children.splice(
|
||||||
|
this.parentNode.children.indexOf(this),
|
||||||
|
1,
|
||||||
|
);
|
||||||
this.parentNode = null;
|
this.parentNode = null;
|
||||||
}
|
}
|
||||||
replaceChildren(...nodes) {
|
replaceChildren(...nodes) {
|
||||||
@@ -59,20 +101,32 @@ function fixture() {
|
|||||||
}
|
}
|
||||||
matches(selector) {
|
matches(selector) {
|
||||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||||
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
|
return (
|
||||||
|
this.tagName === 'BUTTON' &&
|
||||||
|
this.dataset.signalLayer != null &&
|
||||||
|
this.dataset.signalId != null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
|
return selector.startsWith('.')
|
||||||
|
? this.className.split(/\s+/).includes(selector.slice(1))
|
||||||
: this.tagName === selector.toUpperCase();
|
: this.tagName === selector.toUpperCase();
|
||||||
}
|
}
|
||||||
closest(selector) {
|
closest(selector) {
|
||||||
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
|
for (let node = this; node; node = node.parentNode)
|
||||||
|
if (node.matches(selector)) return node;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
querySelectorAll(selector) {
|
querySelectorAll(selector) {
|
||||||
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
|
return this.children.flatMap((child) => [
|
||||||
|
...(child.matches(selector) ? [child] : []),
|
||||||
|
...child.querySelectorAll(selector),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
focus() {
|
focus() {
|
||||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
assert.ok(
|
||||||
|
this.isConnected,
|
||||||
|
'a focus destination must still be connected',
|
||||||
|
);
|
||||||
focusCalls += 1;
|
focusCalls += 1;
|
||||||
document.activeElement = this;
|
document.activeElement = this;
|
||||||
}
|
}
|
||||||
@@ -85,7 +139,11 @@ function fixture() {
|
|||||||
controller.services = { formatAwarenessLabel };
|
controller.services = { formatAwarenessLabel };
|
||||||
controller.signalList = new Node('ol');
|
controller.signalList = new Node('ol');
|
||||||
controller.signalToggle = new Node('button');
|
controller.signalToggle = new Node('button');
|
||||||
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
|
controller.briefTabs = [
|
||||||
|
new Node('button'),
|
||||||
|
new Node('button'),
|
||||||
|
new Node('button'),
|
||||||
|
];
|
||||||
controller.briefPageIndex = 0;
|
controller.briefPageIndex = 0;
|
||||||
controller.signalItems = [];
|
controller.signalItems = [];
|
||||||
controller.signalSignatures = new Map();
|
controller.signalSignatures = new Map();
|
||||||
@@ -93,64 +151,131 @@ function fixture() {
|
|||||||
const display = new Node('button');
|
const display = new Node('button');
|
||||||
const radio = new Node('button');
|
const radio = new Node('button');
|
||||||
const outside = new Node('button');
|
const outside = new Node('button');
|
||||||
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
|
document.body.append(
|
||||||
|
controller.signalToggle,
|
||||||
|
controller.signalList,
|
||||||
|
...controller.briefTabs,
|
||||||
|
display,
|
||||||
|
radio,
|
||||||
|
outside,
|
||||||
|
);
|
||||||
const selected = [];
|
const selected = [];
|
||||||
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
|
controller.services.militaryAwarenessLayer = {
|
||||||
const click = event => controller.handleSignalClick(event);
|
focusTarget: (...args) => selected.push(args),
|
||||||
|
};
|
||||||
|
const click = (event) => controller.handleSignalClick(event);
|
||||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||||
const tab = () => {
|
const tab = () => {
|
||||||
const nodes = document.body.querySelectorAll('button');
|
const nodes = document.body.querySelectorAll('button');
|
||||||
const index = nodes.indexOf(document.activeElement);
|
const index = nodes.indexOf(document.activeElement);
|
||||||
nodes[(index + 1) % nodes.length].focus();
|
nodes[(index + 1) % nodes.length].focus();
|
||||||
};
|
};
|
||||||
return { controller, document, buttons, display, radio, outside, selected, click, tab,
|
return {
|
||||||
losses: () => focusLosses, focusCalls: () => focusCalls };
|
controller,
|
||||||
|
document,
|
||||||
|
buttons,
|
||||||
|
display,
|
||||||
|
radio,
|
||||||
|
outside,
|
||||||
|
selected,
|
||||||
|
click,
|
||||||
|
tab,
|
||||||
|
losses: () => focusLosses,
|
||||||
|
focusCalls: () => focusCalls,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = (id, overrides = {}) => ({
|
const item = (id, overrides = {}) => ({
|
||||||
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
|
key: `flight:flights:${id}`,
|
||||||
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
|
tone: 'nearby',
|
||||||
|
title: `Flight ${id}`,
|
||||||
|
detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||||
|
target: { layerId: 'flights', id },
|
||||||
|
timestamp: 1_700_000_000_000,
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
|
const render = (f, items) => {
|
||||||
|
f.controller.signalItems = items;
|
||||||
|
f.controller.renderCockpitSignals();
|
||||||
|
};
|
||||||
const snapshot = (distanceM = 2000) => ({
|
const snapshot = (distanceM = 2000) => ({
|
||||||
evaluatedAt: 1_700_000_000_000,
|
evaluatedAt: 1_700_000_000_000,
|
||||||
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
||||||
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
|
cohorts: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
count: 1,
|
||||||
|
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||||
const f = fixture();
|
const f = fixture();
|
||||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||||
const target = f.buttons()[1]; target.focus();
|
const target = f.buttons()[1];
|
||||||
|
target.focus();
|
||||||
const calls = f.focusCalls();
|
const calls = f.focusCalls();
|
||||||
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
|
for (let tick = 0; tick < 8; tick += 1)
|
||||||
|
f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||||
assert.equal(f.buttons()[1], target);
|
assert.equal(f.buttons()[1], target);
|
||||||
assert.equal(f.document.activeElement, target);
|
assert.equal(f.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
|
assert.equal(
|
||||||
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
|
f.focusCalls(),
|
||||||
|
calls,
|
||||||
|
'refresh must preserve focus without repeatedly restoring it',
|
||||||
|
);
|
||||||
|
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signal content and accessible name update without replacing the active target', () => {
|
test('signal content and accessible name update without replacing the active target', () => {
|
||||||
const f = fixture(); render(f, [item('a')]);
|
const f = fixture();
|
||||||
const target = f.buttons()[0]; target.focus();
|
render(f, [item('a')]);
|
||||||
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
|
const target = f.buttons()[0];
|
||||||
|
target.focus();
|
||||||
|
render(f, [
|
||||||
|
item('a', {
|
||||||
|
title: 'RENAMED',
|
||||||
|
tone: 'track',
|
||||||
|
detail: 'CURRENT',
|
||||||
|
timestamp: 1_700_000_001_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
assert.equal(f.buttons()[0], target);
|
assert.equal(f.buttons()[0], target);
|
||||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||||
assert.match(target.textContent, /RENAMED/);
|
assert.match(target.textContent, /RENAMED/);
|
||||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||||
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
|
assert.equal(
|
||||||
|
f.controller.signalList.children[0].className,
|
||||||
|
'track actionable',
|
||||||
|
);
|
||||||
assert.equal(f.document.activeElement, target);
|
assert.equal(f.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||||
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
|
const f = fixture();
|
||||||
const target = f.buttons()[1]; target.focus();
|
render(
|
||||||
|
f,
|
||||||
|
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||||
|
);
|
||||||
|
const target = f.buttons()[1];
|
||||||
|
target.focus();
|
||||||
const calls = f.focusCalls();
|
const calls = f.focusCalls();
|
||||||
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
|
for (const order of [
|
||||||
render(f, order.map((id) => item(id)));
|
['d', 'c', 'b', 'a'],
|
||||||
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
|
['b', 'c', 'd', 'a'],
|
||||||
|
['a', 'd', 'c', 'b'],
|
||||||
|
]) {
|
||||||
|
render(
|
||||||
|
f,
|
||||||
|
order.map((id) => item(id)),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
f.buttons().map((button) => button.dataset.signalId),
|
||||||
|
order,
|
||||||
|
);
|
||||||
assert.equal(f.document.activeElement, target);
|
assert.equal(f.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
}
|
}
|
||||||
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
const f = fixture();
|
||||||
const target = f.buttons()[1]; target.focus();
|
render(f, [item('a'), item('b')]);
|
||||||
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
|
const target = f.buttons()[1];
|
||||||
|
target.focus();
|
||||||
|
f.controller.pushCockpitSignal(
|
||||||
|
'status',
|
||||||
|
'warning',
|
||||||
|
'INPUT UNKNOWN',
|
||||||
|
'SOURCE UNAVAILABLE',
|
||||||
|
);
|
||||||
assert.equal(f.document.activeElement, target);
|
assert.equal(f.document.activeElement, target);
|
||||||
assert.equal(f.losses(), 0);
|
assert.equal(f.losses(), 0);
|
||||||
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
|
const event = {
|
||||||
|
target: target.children[0],
|
||||||
|
prevented: false,
|
||||||
|
preventDefault() {
|
||||||
|
this.prevented = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
f.click(event);
|
f.click(event);
|
||||||
assert.equal(event.prevented, true);
|
assert.equal(event.prevented, true);
|
||||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
const f = fixture();
|
||||||
const removed = f.buttons()[1]; removed.focus();
|
render(f, [item('a'), item('b')]);
|
||||||
|
const removed = f.buttons()[1];
|
||||||
|
removed.focus();
|
||||||
const calls = f.focusCalls();
|
const calls = f.focusCalls();
|
||||||
render(f, [item('a')]);
|
render(f, [item('a')]);
|
||||||
assert.equal(removed.isConnected, false);
|
assert.equal(removed.isConnected, false);
|
||||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||||
assert.equal(f.focusCalls(), calls + 1);
|
assert.equal(f.focusCalls(), calls + 1);
|
||||||
f.tab(); f.tab(); f.tab();
|
f.tab();
|
||||||
|
f.tab();
|
||||||
|
f.tab();
|
||||||
assert.equal(f.document.activeElement, f.display);
|
assert.equal(f.document.activeElement, f.display);
|
||||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||||
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
|
assert.equal(
|
||||||
|
f.document.activeElement,
|
||||||
|
f.display,
|
||||||
|
'later refresh must not pull focus back',
|
||||||
|
);
|
||||||
f.tab();
|
f.tab();
|
||||||
assert.equal(f.document.activeElement, f.radio);
|
assert.equal(f.document.activeElement, f.radio);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||||
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
|
const f = fixture();
|
||||||
const removed = f.buttons()[0]; removed.focus();
|
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||||
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
|
const removed = f.buttons()[0];
|
||||||
|
removed.focus();
|
||||||
|
render(f, [
|
||||||
|
item('b', {
|
||||||
|
key: 'shared-status-key',
|
||||||
|
target: { layerId: 'military', id: 'b' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const target = f.buttons()[0];
|
const target = f.buttons()[0];
|
||||||
assert.notEqual(target, removed);
|
assert.notEqual(target, removed);
|
||||||
assert.equal(removed.isConnected, false);
|
assert.equal(removed.isConnected, false);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { msg } from './i18n/marker.js';
|
||||||
|
|
||||||
const CONTEXT_DEPENDENCIES = Object.freeze({
|
const CONTEXT_DEPENDENCIES = Object.freeze({
|
||||||
flights: new Set([
|
flights: new Set([
|
||||||
'military-awareness',
|
'military-awareness',
|
||||||
@@ -9,6 +11,12 @@ const CONTEXT_DEPENDENCIES = Object.freeze({
|
|||||||
'space-missions': new Set(['rocket-launches', 'satellites']),
|
'space-missions': new Set(['rocket-launches', 'satellites']),
|
||||||
});
|
});
|
||||||
const CONTEXT_COMPANIONS = new Set(['radio']);
|
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. */
|
/** Return whether an origin represents a direct user choice on this route. */
|
||||||
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
||||||
return origin === 'user' || origin === 'voice';
|
return origin === 'user' || origin === 'voice';
|
||||||
@@ -336,7 +344,30 @@ export function contextLayerEnableBlockReason({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const label = String(layerName || change.layerId || 'that layer');
|
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
@@ -2,6 +2,8 @@
|
|||||||
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
|
||||||
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
* Cesium retains ownership of showing and hiding its required credit lightbox.
|
||||||
*/
|
*/
|
||||||
|
import { getLocale, t } from './i18n/index.js';
|
||||||
|
|
||||||
export function configureCreditKeyboardAccess(root = document) {
|
export function configureCreditKeyboardAccess(root = document) {
|
||||||
const expand = root?.querySelector?.(
|
const expand = root?.querySelector?.(
|
||||||
'#cesium-credits .cesium-credit-expand-link',
|
'#cesium-credits .cesium-credit-expand-link',
|
||||||
@@ -11,6 +13,14 @@ export function configureCreditKeyboardAccess(root = document) {
|
|||||||
if (!expand || !lightbox || !close) return false;
|
if (!expand || !lightbox || !close) return false;
|
||||||
const overlay = lightbox.parentElement;
|
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';
|
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
|
||||||
expand.setAttribute('role', 'button');
|
expand.setAttribute('role', 'button');
|
||||||
expand.setAttribute('tabindex', '0');
|
expand.setAttribute('tabindex', '0');
|
||||||
@@ -19,7 +29,7 @@ export function configureCreditKeyboardAccess(root = document) {
|
|||||||
expand.setAttribute('aria-expanded', 'false');
|
expand.setAttribute('aria-expanded', 'false');
|
||||||
close.setAttribute('role', 'button');
|
close.setAttribute('role', 'button');
|
||||||
close.setAttribute('tabindex', '0');
|
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;
|
if (expand.dataset.gevKeyboardReady === 'true') return true;
|
||||||
expand.dataset.gevKeyboardReady = 'true';
|
expand.dataset.gevKeyboardReady = 'true';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
|
||||||
|
import { setLocale } from './i18n/index.js';
|
||||||
|
|
||||||
function fakeElement() {
|
function fakeElement() {
|
||||||
const attributes = new Map();
|
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(f.overlay.listenerCount('click'), 1);
|
||||||
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
|
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
@@ -7,6 +7,8 @@
|
|||||||
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
const HELI = new Set([
|
const HELI = new Set([
|
||||||
'EC20',
|
'EC20',
|
||||||
'EC25',
|
'EC25',
|
||||||
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
|||||||
return 'airliner';
|
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({
|
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||||
light: 'Light aircraft',
|
light: msg('Light aircraft'),
|
||||||
glider: 'Glider',
|
glider: msg('Glider'),
|
||||||
turboprop: 'Turboprop',
|
turboprop: msg('Turboprop'),
|
||||||
airliner: 'Airliner',
|
airliner: msg('Airliner'),
|
||||||
widebody: 'Widebody',
|
widebody: msg('Widebody'),
|
||||||
quadjet: 'Four-engine jet',
|
quadjet: msg('Four-engine jet'),
|
||||||
helicopter: 'Helicopter',
|
helicopter: msg('Helicopter'),
|
||||||
fastjet: 'Fast jet',
|
fastjet: msg('Fast jet'),
|
||||||
bizjet: 'Business jet',
|
bizjet: msg('Business jet'),
|
||||||
uav: 'Drone',
|
uav: msg('Drone'),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||||
|
|||||||
@@ -13,18 +13,23 @@
|
|||||||
* broadcast, which does not confirm an incident or its resolution.
|
* 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). */
|
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||||
export const EMERGENCY_TINT_CSS = '#FF4444';
|
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||||
|
|
||||||
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||||
|
|
||||||
const KINDS = {
|
const KINDS = {
|
||||||
general: { label: 'General emergency', severity: 'emergency' },
|
general: { label: msg('General emergency'), severity: 'emergency' },
|
||||||
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
|
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
|
||||||
nordo: { label: 'Radio failure', severity: 'emergency' },
|
nordo: { label: msg('Radio failure'), severity: 'emergency' },
|
||||||
downed: { label: 'Downed aircraft', severity: 'emergency' },
|
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
|
||||||
minfuel: { label: 'Minimum fuel', severity: 'priority' },
|
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
|
||||||
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
|
lifeguard: {
|
||||||
|
label: msg('Lifeguard (medical priority)'),
|
||||||
|
severity: 'priority',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
|
|||||||
if (!env.hasKey) {
|
if (!env.hasKey) {
|
||||||
const actions = terminateOwned('key-absent');
|
const actions = terminateOwned('key-absent');
|
||||||
status = 'missing-key';
|
status = 'missing-key';
|
||||||
|
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||||
error = 'AISSTREAM_API_KEY is not set';
|
error = 'AISSTREAM_API_KEY is not set';
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
keyFingerprint = null;
|
keyFingerprint = null;
|
||||||
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
|
|||||||
if (env.hasTransport === false) {
|
if (env.hasTransport === false) {
|
||||||
const actions = terminateOwned('no-transport');
|
const actions = terminateOwned('no-transport');
|
||||||
status = 'unsupported';
|
status = 'unsupported';
|
||||||
|
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||||
error = 'Node WebSocket transport is unavailable';
|
error = 'Node WebSocket transport is unavailable';
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
|
|||||||
if (!silenceWatchArmed) return [];
|
if (!silenceWatchArmed) return [];
|
||||||
const silentFor = monoNow - silenceSinceMono;
|
const silentFor = monoNow - silenceSinceMono;
|
||||||
if (silentFor >= recycleAfterMs) {
|
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`;
|
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||||
const actions = terminateOwned('silent');
|
const actions = terminateOwned('silent');
|
||||||
scheduleRetry('transport');
|
scheduleRetry('transport');
|
||||||
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
|
|||||||
function onClose(eventGeneration) {
|
function onClose(eventGeneration) {
|
||||||
if (!ownsGeneration(eventGeneration)) return [];
|
if (!ownsGeneration(eventGeneration)) return [];
|
||||||
release();
|
release();
|
||||||
|
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
|
||||||
if (!error) error = 'AISStream websocket closed';
|
if (!error) error = 'AISStream websocket closed';
|
||||||
scheduleRetry('transport');
|
scheduleRetry('transport');
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { t, msg } from '../i18n/index.js';
|
||||||
|
|
||||||
const YOUTUBE_HOSTS = new Set([
|
const YOUTUBE_HOSTS = new Set([
|
||||||
'youtube.com',
|
'youtube.com',
|
||||||
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
|||||||
facebook: Object.freeze(['https://www.facebook.com']),
|
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 _xWidgetsPromise = null;
|
||||||
let _facebookSdkPromise = null;
|
let _facebookSdkPromise = null;
|
||||||
let _facebookEmbedSequence = 0;
|
let _facebookEmbedSequence = 0;
|
||||||
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
|||||||
prior?.();
|
prior?.();
|
||||||
} finally {
|
} finally {
|
||||||
if (facebookSdkReady(globalRef.FB)) finish();
|
if (facebookSdkReady(globalRef.FB)) finish();
|
||||||
else finish(new Error('Facebook player API did not initialize.'));
|
else finish(new Error(msg('Facebook player API did not initialize.')));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
globalRef.fbAsyncInit = ready;
|
globalRef.fbAsyncInit = ready;
|
||||||
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
|||||||
}
|
}
|
||||||
script.addEventListener?.(
|
script.addEventListener?.(
|
||||||
'error',
|
'error',
|
||||||
() => finish(new Error('Facebook player API could not load.')),
|
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
timer =
|
timer =
|
||||||
globalRef.setTimeout?.(
|
globalRef.setTimeout?.(
|
||||||
() => finish(new Error('Facebook player API timed out.')),
|
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||||
EMBED_TIMEOUT_MS,
|
EMBED_TIMEOUT_MS,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
};
|
};
|
||||||
const ready = () => {
|
const ready = () => {
|
||||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||||
else finish(new Error('X embed API did not initialize.'));
|
else finish(new Error(msg('X embed API did not initialize.')));
|
||||||
};
|
};
|
||||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||||
if (!script) {
|
if (!script) {
|
||||||
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
}
|
}
|
||||||
script.addEventListener?.(
|
script.addEventListener?.(
|
||||||
'error',
|
'error',
|
||||||
() => finish(new Error('X embed could not load.')),
|
() => finish(new Error(msg('X embed could not load.'))),
|
||||||
{
|
{
|
||||||
once: true,
|
once: true,
|
||||||
},
|
},
|
||||||
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
timer = setTimeout(
|
timer = setTimeout(
|
||||||
() => finish(new Error('X embed timed out.')),
|
() => finish(new Error(msg('X embed timed out.'))),
|
||||||
EMBED_TIMEOUT_MS,
|
EMBED_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
|||||||
return _xWidgetsPromise;
|
return _xWidgetsPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Outcomes a trimmed YouTube clip reports to the card status.
|
||||||
|
const YOUTUBE_PHASE_LABELS = Object.freeze({
|
||||||
|
unavailable: msg('UNAVAILABLE'),
|
||||||
|
timeout: msg('TIMEOUT'),
|
||||||
|
blocked: msg('BLOCKED'),
|
||||||
|
});
|
||||||
|
|
||||||
function providerLabel(provider) {
|
function providerLabel(provider) {
|
||||||
return (
|
return (
|
||||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
|
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
|
||||||
|
t('SOURCE')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
globalRef.setTimeout?.(() => {
|
globalRef.setTimeout?.(() => {
|
||||||
if (!session.active || session.ready) return;
|
if (!session.active || session.ready) return;
|
||||||
session.error = new Error(
|
session.error = new Error(
|
||||||
|
msg(
|
||||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
session.onError?.(session.error);
|
session.onError?.(session.error);
|
||||||
}, EMBED_TIMEOUT_MS) ?? null;
|
}, EMBED_TIMEOUT_MS) ?? null;
|
||||||
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
startAtSec,
|
startAtSec,
|
||||||
endAtSec,
|
endAtSec,
|
||||||
});
|
});
|
||||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
iframe.title = t('Preloading {provider} media', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
});
|
||||||
iframe.tabIndex = -1;
|
iframe.tabIndex = -1;
|
||||||
iframe.setAttribute('aria-hidden', 'true');
|
iframe.setAttribute('aria-hidden', 'true');
|
||||||
iframe.loading = 'eager';
|
iframe.loading = 'eager';
|
||||||
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mountX(current, token) {
|
async function mountX(current, token) {
|
||||||
current.status.textContent = 'LOADING X POST…';
|
current.status.textContent = t('LOADING X POST…');
|
||||||
try {
|
try {
|
||||||
const api = await xLoader(documentRef, globalRef);
|
const api = await xLoader(documentRef, globalRef);
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
);
|
);
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
if (!widget)
|
if (!widget)
|
||||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
throw new Error(
|
||||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
msg('The X post is unavailable or cannot be embedded.'),
|
||||||
|
);
|
||||||
|
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||||
position();
|
position();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.card.classList.add('is-unavailable');
|
current.card.classList.add('is-unavailable');
|
||||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||||
|
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||||
|
});
|
||||||
position();
|
position();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
session.shouldPlay = autoplay === true;
|
session.shouldPlay = autoplay === true;
|
||||||
session.onReady = () => {
|
session.onReady = () => {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||||
current.card.classList.remove('is-unavailable');
|
current.card.classList.remove('is-unavailable');
|
||||||
position();
|
position();
|
||||||
};
|
};
|
||||||
session.onError = (error) => {
|
session.onError = (error) => {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.card.classList.add('is-unavailable');
|
current.card.classList.add('is-unavailable');
|
||||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||||
|
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||||
|
});
|
||||||
position();
|
position();
|
||||||
};
|
};
|
||||||
session.onPlaybackBlocked = () => {
|
session.onPlaybackBlocked = () => {
|
||||||
if (token !== generation || record !== current) return;
|
if (token !== generation || record !== current) return;
|
||||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
current.status.textContent = t(
|
||||||
|
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||||
|
);
|
||||||
};
|
};
|
||||||
if (session.ready) session.onReady();
|
if (session.ready) session.onReady();
|
||||||
else if (session.error) session.onError(session.error);
|
else if (session.error) session.onError(session.error);
|
||||||
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
card.dataset.provider = source.provider;
|
card.dataset.provider = source.provider;
|
||||||
card.setAttribute(
|
card.setAttribute(
|
||||||
'aria-label',
|
'aria-label',
|
||||||
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
|
t('Embedded {provider} media for {source}', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
source: observation?.title
|
||||||
|
? bhoteEventText(observation.title)
|
||||||
|
: t('witness source'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const leader = documentRef.createElementNS(
|
const leader = documentRef.createElementNS(
|
||||||
'http://www.w3.org/2000/svg',
|
'http://www.w3.org/2000/svg',
|
||||||
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
player.className = 'bhote-embedded-callout-player';
|
player.className = 'bhote-embedded-callout-player';
|
||||||
const footer = documentRef.createElement('footer');
|
const footer = documentRef.createElement('footer');
|
||||||
const title = documentRef.createElement('strong');
|
const title = documentRef.createElement('strong');
|
||||||
title.textContent = String(
|
title.textContent = bhoteEventText(
|
||||||
observation?.shortTitle || observation?.title || 'Witness source',
|
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
const status = documentRef.createElement('small');
|
const status = documentRef.createElement('small');
|
||||||
status.setAttribute('role', 'status');
|
status.setAttribute('role', 'status');
|
||||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
});
|
||||||
const link = documentRef.createElement('a');
|
const link = documentRef.createElement('a');
|
||||||
link.href = source.url;
|
link.href = source.url;
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
link.rel = 'noopener noreferrer';
|
link.rel = 'noopener noreferrer';
|
||||||
link.textContent = 'OPEN ORIGINAL ↗';
|
link.textContent = t('OPEN ORIGINAL ↗');
|
||||||
footer.append(title, status, link);
|
footer.append(title, status, link);
|
||||||
const body = documentRef.createElement('div');
|
const body = documentRef.createElement('div');
|
||||||
body.className = 'bhote-embedded-callout-body';
|
body.className = 'bhote-embedded-callout-body';
|
||||||
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
url.searchParams.set('origin', globalRef.location.origin);
|
url.searchParams.set('origin', globalRef.location.origin);
|
||||||
iframe.src = url.href;
|
iframe.src = url.href;
|
||||||
}
|
}
|
||||||
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
iframe.title = t('{provider} media: {source}', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
source: observation?.title
|
||||||
|
? bhoteEventText(observation.title)
|
||||||
|
: t('witness source'),
|
||||||
|
});
|
||||||
iframe.allow =
|
iframe.allow =
|
||||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||||
iframe.allowFullscreen = true;
|
iframe.allowFullscreen = true;
|
||||||
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
record.youtubeSession.getState().phase,
|
record.youtubeSession.getState().phase,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||||
|
provider: providerLabel(source.provider),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
position();
|
position();
|
||||||
},
|
},
|
||||||
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
|||||||
isCurrent: () => token === generation && record === current,
|
isCurrent: () => token === generation && record === current,
|
||||||
onStatus: (phase) => {
|
onStatus: (phase) => {
|
||||||
if (phase !== 'completed')
|
if (phase !== 'completed')
|
||||||
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||||
|
state: YOUTUBE_PHASE_LABELS[phase]
|
||||||
|
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||||
|
: phase.toUpperCase(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+262
-73
@@ -19,11 +19,13 @@ import {
|
|||||||
SOURCE_PATH_BEAT_IDS,
|
SOURCE_PATH_BEAT_IDS,
|
||||||
} from './bhoteKoshiShotPaths.js';
|
} from './bhoteKoshiShotPaths.js';
|
||||||
import {
|
import {
|
||||||
|
bhoteEventText,
|
||||||
createBhoteKoshiEmbeddedMedia,
|
createBhoteKoshiEmbeddedMedia,
|
||||||
resolveEmbeddedMediaSource,
|
resolveEmbeddedMediaSource,
|
||||||
} from './bhoteKoshiEmbeddedMedia.js';
|
} from './bhoteKoshiEmbeddedMedia.js';
|
||||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||||
|
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||||
|
|
||||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||||
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
|
|||||||
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evidence phases from the event pack, shown as the beat meta prefix.
|
||||||
|
const EVIDENCE_PHASE_LABELS = Object.freeze({
|
||||||
|
initiation: msg('INITIATION'),
|
||||||
|
damming: msg('DAMMING'),
|
||||||
|
'secondary failure': msg('SECONDARY FAILURE'),
|
||||||
|
'border arrival': msg('BORDER ARRIVAL'),
|
||||||
|
propagation: msg('PROPAGATION'),
|
||||||
|
'downstream passage': msg('DOWNSTREAM PASSAGE'),
|
||||||
|
consequence: msg('CONSEQUENCE'),
|
||||||
|
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Display label for an evidence phase; unknown phases show as authored. */
|
||||||
|
function evidencePhaseLabel(phase) {
|
||||||
|
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
|
||||||
|
if (label) return t(label);
|
||||||
|
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
|
||||||
|
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
|
||||||
|
const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||||
|
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
|
||||||
|
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||||
|
function imageryDateText(observedAt) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||||
|
if (!match) return '';
|
||||||
|
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||||
|
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized name and date of an imagery endpoint for display.
|
||||||
|
* @param {{label?: string, observedAt?: string}} entry
|
||||||
|
* @returns {{name: string, date: string}}
|
||||||
|
*/
|
||||||
|
export function imageryEndpointLabel(entry) {
|
||||||
|
const label = String(entry?.label || '');
|
||||||
|
const split = label.lastIndexOf(' · ');
|
||||||
|
const authoredName = split >= 0 ? label.slice(0, split) : label;
|
||||||
|
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
|
||||||
|
let name = authoredName;
|
||||||
|
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
|
||||||
|
const match = pattern.exec(authoredName);
|
||||||
|
if (match) name = t(message, { year: match[1] });
|
||||||
|
}
|
||||||
|
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageryEndpointText(entry) {
|
||||||
|
const { name, date } = imageryEndpointLabel(entry);
|
||||||
|
return [name, date].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||||
|
function renderImageryEndpoint(element, entry) {
|
||||||
|
if (!element) return;
|
||||||
|
const { name, date } = imageryEndpointLabel(entry);
|
||||||
|
element.textContent = date ? `${name} · ` : name;
|
||||||
|
if (!date) return;
|
||||||
|
const dateElement = document.createElement('span');
|
||||||
|
dateElement.className = 'bhote-event-date';
|
||||||
|
dateElement.textContent = date;
|
||||||
|
element.appendChild(dateElement);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||||
export function evidenceCorroborationLabel(observation) {
|
export function evidenceCorroborationLabel(observation) {
|
||||||
const count = Math.max(
|
const count = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||||
);
|
);
|
||||||
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
|
return count > 1
|
||||||
|
? tn(
|
||||||
|
count,
|
||||||
|
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
|
||||||
|
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
|
||||||
|
)
|
||||||
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interpolate headings across north using the shortest angular path. */
|
/** Interpolate headings across north using the shortest angular path. */
|
||||||
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
|
|||||||
}
|
}
|
||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
||||||
ctx.fillText('NO PREVIEW', 20, 30);
|
ctx.fillText(t('NO PREVIEW'), 20, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ellipsizeEvidenceText(value, limit) {
|
function ellipsizeEvidenceText(value, limit) {
|
||||||
@@ -650,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
|
|||||||
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
|
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. */
|
/** Repaint one stable frame with a poster or the current source-video frame. */
|
||||||
export function updateEvidenceFrame(
|
export function updateEvidenceFrame(
|
||||||
canvas,
|
canvas,
|
||||||
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
|
|||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
ellipsizeEvidenceText(
|
ellipsizeEvidenceText(
|
||||||
observation.shortTitle || observation.title,
|
bhoteEventText(observation.shortTitle || observation.title),
|
||||||
layout.titleChars,
|
layout.titleChars,
|
||||||
).toUpperCase(),
|
).toUpperCase(),
|
||||||
16,
|
16,
|
||||||
infoY + 27,
|
infoY + 27,
|
||||||
);
|
);
|
||||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
const publisher = (
|
||||||
const publisher = String(
|
observation.media?.publisher
|
||||||
observation.media?.publisher || 'SOURCE LINK',
|
? evidencePublisherText(observation.media.publisher)
|
||||||
|
: t('SOURCE LINK')
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
fitEvidenceSourceLine(
|
||||||
|
ctx,
|
||||||
|
publisher,
|
||||||
|
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
|
||||||
|
canvas.width - 32,
|
||||||
|
),
|
||||||
16,
|
16,
|
||||||
infoY + 53,
|
infoY + 53,
|
||||||
);
|
);
|
||||||
return true;
|
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. */
|
/** Compose source imagery and caveats into one stable renderer-owned frame. */
|
||||||
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
export function createEvidenceFrame(observation, poster = null, options = {}) {
|
||||||
const canvas = evidenceFrameCanvas(observation);
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function createPanel(event, handlers) {
|
function createPanel(event, handlers) {
|
||||||
const panel = document.createElement('aside');
|
const panel = document.createElement('aside');
|
||||||
panel.id = 'bhote-koshi-event-panel';
|
panel.id = 'bhote-koshi-event-panel';
|
||||||
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
|
panel.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
t('Bhote Koshi flood reconstruction controls'),
|
||||||
|
);
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="bhote-event-header">
|
<div class="bhote-event-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
|
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="bhote-event-status-row">
|
<div class="bhote-event-status-row">
|
||||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||||
</div>
|
</div>
|
||||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||||
<div class="bhote-event-section-head">
|
<div class="bhote-event-section-head">
|
||||||
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
|
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||||
<span data-role="split-readout">50 / 50</span>
|
<span data-role="split-readout">50 / 50</span>
|
||||||
</div>
|
</div>
|
||||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
|
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||||
<div class="bhote-event-dates">
|
<div class="bhote-event-dates">
|
||||||
<span data-role="before-date"></span>
|
<span data-role="before-date"></span>
|
||||||
<span data-role="after-date"></span>
|
<span data-role="after-date"></span>
|
||||||
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
|
|||||||
</section>
|
</section>
|
||||||
<section class="bhote-event-section">
|
<section class="bhote-event-section">
|
||||||
<div class="bhote-event-section-head">
|
<div class="bhote-event-section-head">
|
||||||
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
|
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||||
<span data-role="time"></span>
|
<span data-role="time"></span>
|
||||||
</div>
|
</div>
|
||||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||||
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
|
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||||
<div class="bhote-event-story-current">
|
<div class="bhote-event-story-current">
|
||||||
<span data-role="beat-index">01 / 06</span>
|
<span data-role="beat-index">01 / 06</span>
|
||||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
|
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="bhote-event-actions">
|
<div class="bhote-event-actions">
|
||||||
<button type="button" data-action="play">▶ PLAY</button>
|
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||||
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
|
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||||
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
|
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<details class="bhote-event-evidence">
|
<details class="bhote-event-evidence">
|
||||||
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
|
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||||
<div class="bhote-event-report-list"></div>
|
<div class="bhote-event-report-list"></div>
|
||||||
</details>
|
</details>
|
||||||
<div class="bhote-event-credit">
|
<div class="bhote-event-credit">
|
||||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
|
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const refs = collectPanelReferences(panel);
|
const refs = collectPanelReferences(panel);
|
||||||
refs.beforeDate.textContent = event.imagery.before.label;
|
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||||
refs.afterDate.textContent = event.imagery.after.label;
|
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||||
refs.caveat.textContent = event.reconstruction.caveat;
|
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||||
|
|
||||||
for (const report of event.fieldReports) {
|
for (const report of event.fieldReports) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
|
|||||||
const status = document.createElement('small');
|
const status = document.createElement('small');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'bhote-event-report';
|
button.className = 'bhote-event-report';
|
||||||
label.textContent = report.label;
|
label.textContent = bhoteEventText(report.label);
|
||||||
status.textContent = report.status;
|
status.textContent = bhoteEventText(report.status);
|
||||||
button.append(label, status);
|
button.append(label, status);
|
||||||
button.addEventListener('click', () => openExternal(report.url));
|
button.addEventListener('click', () => openExternal(report.url));
|
||||||
refs.reportList.appendChild(button);
|
refs.reportList.appendChild(button);
|
||||||
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_embeddedMedia?.show?.({
|
_embeddedMedia?.show?.({
|
||||||
observation: {
|
observation: {
|
||||||
title: witness.title,
|
title: witness.title,
|
||||||
shortTitle: 'Rasuwagadhi witness',
|
shortTitle: t('Rasuwagadhi witness'),
|
||||||
media: { sourceUrl: witness.sourceUrl },
|
media: { sourceUrl: witness.sourceUrl },
|
||||||
},
|
},
|
||||||
anchor,
|
anchor,
|
||||||
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
|
|||||||
collisionGroup: 'ambient-card',
|
collisionGroup: 'ambient-card',
|
||||||
zIndex: 60,
|
zIndex: 60,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
|
accessibilityLabel: t(
|
||||||
|
'Open original source for {source}; capture time unverified',
|
||||||
|
{ source: bhoteEventText(observation.title) },
|
||||||
|
),
|
||||||
activate: () => openExternal(observation.media.sourceUrl),
|
activate: () => openExternal(observation.media.sourceUrl),
|
||||||
minDistance: 0,
|
minDistance: 0,
|
||||||
maxDistance: 220000,
|
maxDistance: 220000,
|
||||||
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
position: evidencePositionGetter(index, true),
|
position: evidencePositionGetter(index, true),
|
||||||
cullPosition: anchor,
|
cullPosition: anchor,
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
|
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
|
||||||
details: [],
|
details: [],
|
||||||
accent: WORLD_OVERLAY_STYLE.accent,
|
accent: WORLD_OVERLAY_STYLE.accent,
|
||||||
priority: 80_000 - index,
|
priority: 80_000 - index,
|
||||||
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
|
|||||||
id: 'bhote-koshi-split-line',
|
id: 'bhote-koshi-split-line',
|
||||||
handleClass: 'bhote-koshi-split-handle',
|
handleClass: 'bhote-koshi-split-handle',
|
||||||
cssProperty: '--bhote-koshi-split',
|
cssProperty: '--bhote-koshi-split',
|
||||||
beforeTitle: event.imagery.before.label,
|
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||||
afterTitle: event.imagery.after.label,
|
afterTitle: imageryEndpointText(event.imagery.after),
|
||||||
ariaLabel: 'Historical reference and post-event image divider',
|
ariaLabel: t('Historical reference and post-event image divider'),
|
||||||
formatValueText: (beforePercent, afterPercent) =>
|
formatValueText: (beforePercent, afterPercent) =>
|
||||||
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
|
t(
|
||||||
|
'A historical reference {before} percent, B post-event {after} percent',
|
||||||
|
{ before: beforePercent, after: afterPercent },
|
||||||
|
),
|
||||||
onChange: setSplit,
|
onChange: setSplit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
|
|||||||
setElementProperty(
|
setElementProperty(
|
||||||
button,
|
button,
|
||||||
'textContent',
|
'textContent',
|
||||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||||
);
|
);
|
||||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||||
}
|
}
|
||||||
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.progressInput,
|
_panelRefs.progressInput,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? 'Seek Nepal scene clock'
|
? t('Seek Nepal scene clock')
|
||||||
: 'Schematic downstream progression',
|
: t('Schematic downstream progression'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.timelineLabel,
|
_panelRefs.timelineLabel,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||||
);
|
);
|
||||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
|
|||||||
);
|
);
|
||||||
const playLabel = sceneDirected
|
const playLabel = sceneDirected
|
||||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||||
? '… PLAYING SHOT'
|
? t('… PLAYING SHOT')
|
||||||
: '▶ PLAY SHOT'
|
: t('▶ PLAY SHOT')
|
||||||
: _playing
|
: _playing
|
||||||
? 'Ⅱ PAUSE'
|
? t('Ⅱ PAUSE')
|
||||||
: _progress >= 1
|
: _progress >= 1
|
||||||
? _cinematicReplayArmed
|
? _cinematicReplayArmed
|
||||||
? '↺ REPLAY CINEMATIC'
|
? t('↺ REPLAY CINEMATIC')
|
||||||
: '↺ REPLAY'
|
: t('↺ REPLAY')
|
||||||
: '▶ PLAY';
|
: t('▶ PLAY');
|
||||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
'textContent',
|
'textContent',
|
||||||
_sceneActionPending?.type === 'scene'
|
_sceneActionPending?.type === 'scene'
|
||||||
? '… PLAYING SCENE'
|
? t('… PLAYING SCENE')
|
||||||
: '▶ PLAY SCENE',
|
: t('▶ PLAY SCENE'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
|
|||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.playSceneButton,
|
_panelRefs.playSceneButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
|
t('Continue {scene} from next shot', {
|
||||||
|
scene:
|
||||||
|
_sceneContext?.sceneDisplayTitle ||
|
||||||
|
_sceneContext?.sceneTitle ||
|
||||||
|
t('current scene'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.storyReplayButton,
|
_panelRefs.storyReplayButton,
|
||||||
'textContent',
|
'textContent',
|
||||||
'↺ FULL STORY',
|
t('↺ FULL STORY'),
|
||||||
);
|
);
|
||||||
if (!sceneDirected) {
|
if (!sceneDirected) {
|
||||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.storyReplayButton,
|
_panelRefs.storyReplayButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
'Replay the full reconstruction',
|
t('Replay the full reconstruction'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const beatIndex = currentEvidenceIndex();
|
const beatIndex = currentEvidenceIndex();
|
||||||
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.beatTitle,
|
_panelRefs.beatTitle,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
? _sceneContext?.shotDisplayTitle ||
|
||||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
_sceneContext?.shotTitle ||
|
||||||
|
t('SCENE SHOT')
|
||||||
|
: observation?.shortTitle || observation?.title
|
||||||
|
? bhoteEventText(observation.shortTitle || observation.title)
|
||||||
|
: t('EVIDENCE'),
|
||||||
);
|
);
|
||||||
const sourceCount = Math.max(
|
const sourceCount = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||||
);
|
);
|
||||||
const beatMeta = [
|
const beatMeta = [
|
||||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
evidencePhaseLabel(observation?.phase),
|
||||||
'TIME UNVERIFIED',
|
t('TIME UNVERIFIED'),
|
||||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
sourceCount > 1
|
||||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||||
|
: '',
|
||||||
|
observation?.imageryCoverage === 'outside'
|
||||||
|
? t('OUTSIDE IMAGERY SWIPE')
|
||||||
|
: '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' / ');
|
.join(' / ');
|
||||||
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.beatMeta,
|
_panelRefs.beatMeta,
|
||||||
'textContent',
|
'textContent',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
|
? t('{scene} / AUTHORED SHOT', {
|
||||||
|
scene: String(
|
||||||
|
_sceneContext?.sceneDisplayTitle ||
|
||||||
|
_sceneContext?.sceneTitle ||
|
||||||
|
t('NEPAL FLOOD INCIDENT'),
|
||||||
|
).toUpperCase(),
|
||||||
|
})
|
||||||
: beatMeta,
|
: beatMeta,
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
|
|||||||
_panelRefs.playButton,
|
_panelRefs.playButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected
|
sceneDirected
|
||||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
? t('Play {shot}', {
|
||||||
: 'Play reconstruction',
|
shot:
|
||||||
|
_sceneContext?.shotDisplayTitle ||
|
||||||
|
_sceneContext?.shotTitle ||
|
||||||
|
t('current scene shot'),
|
||||||
|
})
|
||||||
|
: t('Play reconstruction'),
|
||||||
);
|
);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.previousButton,
|
_panelRefs.previousButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||||
);
|
);
|
||||||
setElementAttribute(
|
setElementAttribute(
|
||||||
_panelRefs.nextButton,
|
_panelRefs.nextButton,
|
||||||
'aria-label',
|
'aria-label',
|
||||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||||
);
|
);
|
||||||
setElementProperty(
|
setElementProperty(
|
||||||
_panelRefs.openSourceButton,
|
_panelRefs.openSourceButton,
|
||||||
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
|
|||||||
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
||||||
if (_panelRefs?.caveat) {
|
if (_panelRefs?.caveat) {
|
||||||
_panelRefs.caveat.textContent = imageryAvailable
|
_panelRefs.caveat.textContent = imageryAvailable
|
||||||
? _event?.reconstruction?.caveat || ''
|
? bhoteEventText(_event?.reconstruction?.caveat || '')
|
||||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||||
}
|
}
|
||||||
if (_panelRefs?.imageryCloudNote)
|
if (_panelRefs?.imageryCloudNote)
|
||||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||||
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
|
|||||||
count: _enabled ? 1 : 0,
|
count: _enabled ? 1 : 0,
|
||||||
status: _enabled ? 'nominal' : 'idle',
|
status: _enabled ? 'nominal' : 'idle',
|
||||||
source: 'Vantor + GeoPera',
|
source: 'Vantor + GeoPera',
|
||||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
coverage: _enabled
|
||||||
|
? t('Rasuwa · 26 Aug 2026')
|
||||||
|
: t('Event reconstruction'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getPlaybackState() {
|
getPlaybackState() {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from './dataCredits.js';
|
} from './dataCredits.js';
|
||||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||||
|
import { t, msg, getLocale } from '../i18n/index.js';
|
||||||
|
|
||||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||||
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
|
|||||||
const TRIGGER_SOURCE_URL =
|
const TRIGGER_SOURCE_URL =
|
||||||
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
||||||
|
|
||||||
|
// 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({
|
const INCIDENT = Object.freeze({
|
||||||
lat: 28.3632333,
|
lat: 28.3632333,
|
||||||
lon: 85.4423639,
|
lon: 85.4423639,
|
||||||
heightM: 5200,
|
heightM: 5200,
|
||||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||||
subtitle: 'NEPAL · 26 AUG 2026',
|
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||||
});
|
});
|
||||||
const TRIGGER_RECORD = Object.freeze({
|
const TRIGGER_RECORD = Object.freeze({
|
||||||
lat: 28.271,
|
lat: 28.271,
|
||||||
lon: 85.515,
|
lon: 85.515,
|
||||||
heightM: 4200,
|
heightM: 4200,
|
||||||
title: 'USGS INITIAL REPORT',
|
title: msg('USGS INITIAL REPORT'),
|
||||||
details: Object.freeze([
|
details: Object.freeze([
|
||||||
'26 AUG 2026 · 08:37 NPT',
|
msg('26 AUG 2026 · 08:37 NPT'),
|
||||||
'28.2710° N · 85.5150° E',
|
msg('28.2710° N · 85.5150° E'),
|
||||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||||
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
|
|||||||
lat: 28.3949,
|
lat: 28.3949,
|
||||||
lon: 84.124,
|
lon: 84.124,
|
||||||
heightM: 6800,
|
heightM: 6800,
|
||||||
|
// i18n-ignore: country name, shown as is
|
||||||
title: 'NEPAL',
|
title: 'NEPAL',
|
||||||
});
|
});
|
||||||
const NEARBY_CITIES = Object.freeze([
|
const NEARBY_CITIES = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'kathmandu',
|
id: 'kathmandu',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'KATHMANDU',
|
title: 'KATHMANDU',
|
||||||
lat: 27.7017,
|
lat: 27.7017,
|
||||||
lon: 85.3206,
|
lon: 85.3206,
|
||||||
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'bhaktapur',
|
id: 'bhaktapur',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'BHAKTAPUR',
|
title: 'BHAKTAPUR',
|
||||||
lat: 27.673,
|
lat: 27.673,
|
||||||
lon: 85.43,
|
lon: 85.43,
|
||||||
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'dhulikhel',
|
id: 'dhulikhel',
|
||||||
|
// i18n-ignore: city name
|
||||||
title: 'DHULIKHEL',
|
title: 'DHULIKHEL',
|
||||||
lat: 27.6221,
|
lat: 27.6221,
|
||||||
lon: 85.5428,
|
lon: 85.5428,
|
||||||
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'card',
|
variant: 'card',
|
||||||
paintLane: 'thumbnail',
|
paintLane: 'thumbnail',
|
||||||
title: subject.title,
|
title: t(subject.title),
|
||||||
details: [subject.subtitle],
|
details: [t(subject.subtitle)],
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: () => presentation().alpha,
|
sourceAlpha: () => presentation().alpha,
|
||||||
presentationScale: () => presentation().scale,
|
presentationScale: () => presentation().scale,
|
||||||
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'card',
|
variant: 'card',
|
||||||
paintLane: 'thumbnail',
|
paintLane: 'thumbnail',
|
||||||
title: TRIGGER_RECORD.title,
|
title: t(TRIGGER_RECORD.title),
|
||||||
details: [...TRIGGER_RECORD.details],
|
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: () => presentation().alpha,
|
sourceAlpha: () => presentation().alpha,
|
||||||
presentationScale: () => presentation().scale,
|
presentationScale: () => presentation().scale,
|
||||||
@@ -549,8 +573,9 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
protected: true,
|
protected: true,
|
||||||
active: true,
|
active: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
accessibilityLabel:
|
accessibilityLabel: t(
|
||||||
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
|
||||||
|
),
|
||||||
activate: () => {
|
activate: () => {
|
||||||
const opened = window.open(
|
const opened = window.open(
|
||||||
TRIGGER_SOURCE_URL,
|
TRIGGER_SOURCE_URL,
|
||||||
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
position,
|
position,
|
||||||
variant: 'label',
|
variant: 'label',
|
||||||
paintLane: 'ambient-label',
|
paintLane: 'ambient-label',
|
||||||
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
|
||||||
details: [],
|
details: [],
|
||||||
accent: INCIDENT_ACCENT,
|
accent: INCIDENT_ACCENT,
|
||||||
sourceAlpha: progress,
|
sourceAlpha: progress,
|
||||||
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
|
|||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'nepal-context',
|
id: 'nepal-context',
|
||||||
|
// i18n-ignore: country name
|
||||||
label: 'NEPAL',
|
label: 'NEPAL',
|
||||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Show the Nepal context callout without a border or country fill',
|
'Show the Nepal context callout without a border or country fill',
|
||||||
|
),
|
||||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-regional',
|
id: 'bhote-koshi-regional',
|
||||||
|
// i18n-ignore: river name (Bhote Koshi)
|
||||||
label: 'BHOTE',
|
label: 'BHOTE',
|
||||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-city-context',
|
id: 'bhote-koshi-city-context',
|
||||||
label: 'CITIES',
|
label: t('CITIES'),
|
||||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Keep the regional incident context and introduce nearby city labels',
|
'Keep the regional incident context and introduce nearby city labels',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-incident-places',
|
id: 'bhote-koshi-incident-places',
|
||||||
label: 'PLACES',
|
label: t('PLACES'),
|
||||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-flood-path',
|
id: 'bhote-koshi-flood-path',
|
||||||
label: 'PATH',
|
label: t('PATH'),
|
||||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-path-overview',
|
id: 'bhote-koshi-path-overview',
|
||||||
label: 'OVERVIEW',
|
label: t('OVERVIEW'),
|
||||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||||
title: 'Keep the completed flood path and location labels visible',
|
title: t(
|
||||||
|
'Keep the completed flood path and location labels visible',
|
||||||
|
),
|
||||||
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bhote-koshi-trigger-record',
|
id: 'bhote-koshi-trigger-record',
|
||||||
label: 'RECORD',
|
label: t('RECORD'),
|
||||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
title:
|
title: t(
|
||||||
'Reveal the trigger record while making a slow camera approach',
|
'Reveal the trigger record while making a slow camera approach',
|
||||||
|
),
|
||||||
params: {
|
params: {
|
||||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
preview: true,
|
preview: true,
|
||||||
|
|||||||
+397
-110
@@ -1,9 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
import {
|
||||||
|
INCIDENT_OVERVIEW_HOLD_SEC,
|
||||||
|
INCIDENT_OVERVIEW_PLACES,
|
||||||
|
} from './bhoteKoshiIncidentPlaces.js';
|
||||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
import { SceneDirector } from '../scenes/director.js';
|
import { SceneDirector } from '../scenes/director.js';
|
||||||
import { createPlaybackClock } from '../director/clock.js';
|
import { createPlaybackClock } from '../director/clock.js';
|
||||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||||
@@ -29,8 +33,12 @@ function viewerFixture() {
|
|||||||
return {
|
return {
|
||||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||||
dataSources: {
|
dataSources: {
|
||||||
add(source) { added.push(source); },
|
add(source) {
|
||||||
remove(source) { removed.push(source); },
|
added.push(source);
|
||||||
|
},
|
||||||
|
remove(source) {
|
||||||
|
removed.push(source);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
_test: { added, removed },
|
_test: { added, removed },
|
||||||
};
|
};
|
||||||
@@ -38,7 +46,9 @@ function viewerFixture() {
|
|||||||
|
|
||||||
function nonRunningAnimation() {
|
function nonRunningAnimation() {
|
||||||
return {
|
return {
|
||||||
scheduleFrame() { return 1; },
|
scheduleFrame() {
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
cancelFrame() {},
|
cancelFrame() {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,24 +68,68 @@ function overlayHostFixture() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
assert.deepEqual(
|
||||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
closeRegionRing([
|
||||||
]);
|
[80, 26],
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
[88, 26],
|
||||||
[80, 26], [88, 26], [80, 26],
|
[88, 30],
|
||||||
]);
|
]),
|
||||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
[
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 26],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
closeRegionRing([
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[80, 26],
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[80, 26],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
closeRegionRing([
|
||||||
|
[80, 26],
|
||||||
|
['bad', 27],
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
const main = [
|
||||||
const island = [[82, 25], [83, 25], [82, 25]];
|
[80, 26],
|
||||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
[88, 26],
|
||||||
assert.equal(largestGeoJsonRing({
|
[88, 30],
|
||||||
|
[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',
|
type: 'MultiPolygon',
|
||||||
coordinates: [[island], [main]],
|
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', () => {
|
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||||
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
|
|||||||
new Cesium.Cartesian3(10, 0, 0),
|
new Cesium.Cartesian3(10, 0, 0),
|
||||||
new Cesium.Cartesian3(20, 0, 0),
|
new Cesium.Cartesian3(20, 0, 0),
|
||||||
];
|
];
|
||||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
assert.deepEqual(revealPolylinePositions(positions, 0), [
|
||||||
|
positions[0],
|
||||||
|
positions[0],
|
||||||
|
]);
|
||||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||||
positions[0],
|
positions[0],
|
||||||
new Cesium.Cartesian3(5, 0, 0),
|
new Cesium.Cartesian3(5, 0, 0),
|
||||||
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
|||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender: (reason) => renders.push(reason),
|
requestRender: (reason) => renders.push(reason),
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
|||||||
const publication = overlayHost._test.publications.at(-1);
|
const publication = overlayHost._test.publications.at(-1);
|
||||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||||
assert.equal(publication.entries.length, 2);
|
assert.equal(publication.entries.length, 2);
|
||||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
const nepal = publication.entries.find(
|
||||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
({ id }) => id === 'nepal-context-callout',
|
||||||
|
);
|
||||||
|
const incident = publication.entries.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||||
|
);
|
||||||
assert.equal(nepal.variant, 'label');
|
assert.equal(nepal.variant, 'label');
|
||||||
assert.equal(nepal.title, 'NEPAL');
|
assert.equal(nepal.title, 'NEPAL');
|
||||||
assert.equal(nepal.anchorDot, true);
|
assert.equal(nepal.anchorDot, true);
|
||||||
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
|||||||
assert.equal(incident.contentAlpha(), 1);
|
assert.equal(incident.contentAlpha(), 1);
|
||||||
assert.equal(incident.presentationScale(), 1);
|
assert.equal(incident.presentationScale(), 1);
|
||||||
assert.equal(layer.getStats().status, 'nominal');
|
assert.equal(layer.getStats().status, 'nominal');
|
||||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||||
|
});
|
||||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||||
|
|
||||||
assert.equal(await layer.disable(), true);
|
assert.equal(await layer.disable(), true);
|
||||||
assert.equal(viewer._test.removed.length, 1);
|
assert.equal(viewer._test.removed.length, 1);
|
||||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
assert.equal(
|
||||||
|
overlayHost._test.clears.at(-1),
|
||||||
|
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||||
|
);
|
||||||
assert.equal(layer.getStats().status, 'idle');
|
assert.equal(layer.getStats().status, 'idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
|||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -199,15 +275,18 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
|||||||
|
|
||||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
assert.deepEqual(
|
||||||
'nepal-context-halo',
|
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||||
]);
|
['nepal-context-halo'],
|
||||||
|
);
|
||||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||||
assert.equal(nepalLabel.variant, 'label');
|
assert.equal(nepalLabel.variant, 'label');
|
||||||
assert.equal(nepalLabel.placement, 'right');
|
assert.equal(nepalLabel.placement, 'right');
|
||||||
assert.equal(nepalLabel.anchorDot, true);
|
assert.equal(nepalLabel.anchorDot, true);
|
||||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
assert.deepEqual(
|
||||||
|
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
|
||||||
|
[
|
||||||
{ id: 'nepal-context', active: true },
|
{ id: 'nepal-context', active: true },
|
||||||
{ id: 'bhote-koshi-regional', active: false },
|
{ id: 'bhote-koshi-regional', active: false },
|
||||||
{ id: 'bhote-koshi-city-context', 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-flood-path', active: false },
|
||||||
{ id: 'bhote-koshi-path-overview', active: false },
|
{ id: 'bhote-koshi-path-overview', active: false },
|
||||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
overlayHost._test.publications.at(-1).entries
|
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
overlayHost._test.publications
|
||||||
|
.at(-1)
|
||||||
|
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||||
'card',
|
'card',
|
||||||
);
|
);
|
||||||
|
|
||||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
assert.deepEqual(
|
||||||
'nepal-context-halo',
|
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||||
]);
|
['nepal-context-halo'],
|
||||||
|
);
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
|||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
let pulseFrame = null;
|
let pulseFrame = null;
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => null, requestRender() {},
|
boundaryResolver: async () => null,
|
||||||
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
now: () => nowMs,
|
now: () => nowMs,
|
||||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
scheduleFrame(callback) {
|
||||||
|
pulseFrame = callback;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
cancelFrame() {},
|
cancelFrame() {},
|
||||||
});
|
});
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
const source = viewer._test.added[0];
|
const source = viewer._test.added[0];
|
||||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
const event = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
new URL(
|
||||||
|
'../../public/events/bhote-koshi-2026/event.json',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.equal(event.evidenceSpine.length, 16);
|
assert.equal(event.evidenceSpine.length, 16);
|
||||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||||
assert.equal(source.entities.values.length, 15);
|
assert.equal(source.entities.values.length, 15);
|
||||||
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
|||||||
const id = `bhote-koshi-place-${point.id}`;
|
const id = `bhote-koshi-place-${point.id}`;
|
||||||
const pin = labels.find((entry) => entry.id === id);
|
const pin = labels.find((entry) => entry.id === id);
|
||||||
assert.ok(pin, point.id);
|
assert.ok(pin, point.id);
|
||||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
assert.equal(
|
||||||
|
pin.title,
|
||||||
|
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
|
||||||
|
);
|
||||||
assert.equal(pin.variant, 'label');
|
assert.equal(pin.variant, 'label');
|
||||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
assert.ok(
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
|
||||||
|
);
|
||||||
nowMs = 349 + index * 650;
|
nowMs = 349 + index * 650;
|
||||||
assert.equal(pin.sourceAlpha(), 0);
|
assert.equal(pin.sourceAlpha(), 0);
|
||||||
nowMs += 1;
|
nowMs += 1;
|
||||||
assert.equal(pin.sourceAlpha(), 0);
|
assert.equal(pin.sourceAlpha(), 0);
|
||||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
assert.equal(
|
||||||
|
source.entities.values.filter(
|
||||||
|
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
nowMs += 520;
|
nowMs += 520;
|
||||||
assert.equal(pin.sourceAlpha(), 1);
|
assert.equal(pin.sourceAlpha(), 1);
|
||||||
}
|
}
|
||||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||||
// A repeat halo cycle must not erase already revealed labels.
|
// A repeat halo cycle must not erase already revealed labels.
|
||||||
nowMs = 350 + 15 * 650;
|
nowMs = 350 + 15 * 650;
|
||||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
assert.equal(
|
||||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
source.entities
|
||||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
|
||||||
|
.show.getValue(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
labels
|
||||||
|
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
|
||||||
|
.sourceAlpha(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(typeof pulseFrame, 'function');
|
assert.equal(typeof pulseFrame, 'function');
|
||||||
pulseFrame();
|
pulseFrame();
|
||||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
assert.equal(
|
||||||
|
source.entities.values.some((entity) =>
|
||||||
|
entity.id.startsWith('bhote-koshi-place-'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await layer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
|
||||||
|
t.after(() => setLocale('en'));
|
||||||
|
setLocale('ru');
|
||||||
|
const viewer = viewerFixture();
|
||||||
|
const overlayHost = overlayHostFixture();
|
||||||
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
|
boundaryResolver: async () => null,
|
||||||
|
requestRender() {},
|
||||||
|
overlayHost,
|
||||||
|
now: () => 0,
|
||||||
|
...nonRunningAnimation(),
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||||
|
await layer.enable(viewer);
|
||||||
|
const titles = new Map(
|
||||||
|
overlayHost._test.publications
|
||||||
|
.at(-1)
|
||||||
|
.entries.map(({ id, title }) => [id, title]),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
|
||||||
|
'01 · ВИД НА ОБРУШЕНИЕ',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
titles.get('bhote-koshi-place-gyirong-border-gate'),
|
||||||
|
'04 · ПОГРАНПЕРЕХОД',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
|
||||||
|
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
|
||||||
|
'14 · МОСТ BIDUR',
|
||||||
|
);
|
||||||
|
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
|||||||
const source = viewer._test.added[0];
|
const source = viewer._test.added[0];
|
||||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||||
const labels = overlayHost._test.publications.at(-1).entries;
|
const labels = overlayHost._test.publications.at(-1).entries;
|
||||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
const trigger = labels.find(
|
||||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
|
||||||
|
);
|
||||||
|
const bidur = labels.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(source.entities.values.length, 1);
|
assert.equal(source.entities.values.length, 1);
|
||||||
assert.equal(labels.length, 15);
|
assert.equal(labels.length, 15);
|
||||||
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
|||||||
}
|
}
|
||||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
assert.ok(
|
||||||
|
path.polyline.material.color
|
||||||
|
.getValue()
|
||||||
|
.equalsEpsilon(
|
||||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||||
Cesium.Math.EPSILON7,
|
Cesium.Math.EPSILON7,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
assert.equal(path.show.getValue(), false);
|
assert.equal(path.show.getValue(), false);
|
||||||
assert.equal(trigger.sourceAlpha(), 1);
|
assert.equal(trigger.sourceAlpha(), 1);
|
||||||
assert.equal(bidur.sourceAlpha(), 1);
|
assert.equal(bidur.sourceAlpha(), 1);
|
||||||
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
|||||||
const halfwayCount = path.polyline.positions.getValue().length;
|
const halfwayCount = path.polyline.positions.getValue().length;
|
||||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||||
nowMs = 4250;
|
nowMs = 4250;
|
||||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
assert.equal(
|
||||||
|
path.polyline.positions.getValue().length,
|
||||||
|
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||||
|
);
|
||||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||||
// More observation pins must not extend or straighten the sourced route.
|
// More observation pins must not extend or straighten the sourced route.
|
||||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
assert.deepEqual(
|
||||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
path.polyline.positions.getValue(),
|
||||||
)));
|
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||||
|
});
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
|||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
|||||||
assert.equal(labels.length, 15);
|
assert.equal(labels.length, 15);
|
||||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
assert.equal(
|
||||||
|
pin.title,
|
||||||
|
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||||
|
);
|
||||||
assert.equal(pin.sourceAlpha(), 1);
|
assert.equal(pin.sourceAlpha(), 1);
|
||||||
}
|
}
|
||||||
assert.equal(path.show.getValue(), true);
|
assert.equal(path.show.getValue(), true);
|
||||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
assert.equal(
|
||||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
path.polyline.positions.getValue().length,
|
||||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Cesium.Cartesian3.equals(
|
||||||
|
path.polyline.positions.getValue()[0],
|
||||||
|
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
source.entities.getById(
|
||||||
|
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
|
||||||
|
),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||||
|
});
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
|
|||||||
const publication = overlayHost._test.publications.at(-1);
|
const publication = overlayHost._test.publications.at(-1);
|
||||||
const record = publication.entries[0];
|
const record = publication.entries[0];
|
||||||
|
|
||||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'bhote-koshi-flood-path',
|
source.entities.values.map(({ id }) => id),
|
||||||
'bhote-koshi-trigger-record-halo',
|
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
|
||||||
]);
|
);
|
||||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||||
assert.deepEqual(record.details, [
|
assert.deepEqual(record.details, [
|
||||||
'26 AUG 2026 · 08:37 NPT',
|
'26 AUG 2026 · 08:37 NPT',
|
||||||
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
|||||||
viewer.camera = {
|
viewer.camera = {
|
||||||
moveStart,
|
moveStart,
|
||||||
moveEnd,
|
moveEnd,
|
||||||
flyTo(options) { flights.push(options); },
|
flyTo(options) {
|
||||||
|
flights.push(options);
|
||||||
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
|||||||
assert.equal(flights.length, 1);
|
assert.equal(flights.length, 1);
|
||||||
assert.equal(flights[0].duration, 4.6);
|
assert.equal(flights[0].duration, 4.6);
|
||||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
assert.ok(
|
||||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
|
||||||
|
);
|
||||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||||
flights[0].complete();
|
flights[0].complete();
|
||||||
assert.equal(flights.length, 2);
|
assert.equal(flights.length, 2);
|
||||||
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
const moveStart = new Cesium.Event();
|
const moveStart = new Cesium.Event();
|
||||||
const moveEnd = new Cesium.Event();
|
const moveEnd = new Cesium.Event();
|
||||||
viewer.camera = {
|
viewer.camera = {
|
||||||
moveStart, moveEnd,
|
moveStart,
|
||||||
flyTo(options) { flights.push(options); },
|
moveEnd,
|
||||||
|
flyTo(options) {
|
||||||
|
flights.push(options);
|
||||||
|
},
|
||||||
cancelFlight() {},
|
cancelFlight() {},
|
||||||
};
|
};
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
timers.set(++nextTimer, callback);
|
timers.set(++nextTimer, callback);
|
||||||
return nextTimer;
|
return nextTimer;
|
||||||
},
|
},
|
||||||
cancelDelay(id) { timers.delete(id); },
|
cancelDelay(id) {
|
||||||
|
timers.delete(id);
|
||||||
|
},
|
||||||
...nonRunningAnimation(),
|
...nonRunningAnimation(),
|
||||||
});
|
});
|
||||||
const director = {
|
const director = {
|
||||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
_clock: createPlaybackClock({
|
||||||
|
isRunning: () => false,
|
||||||
|
timingForShot: () => null,
|
||||||
|
onProgress() {},
|
||||||
|
}),
|
||||||
_scenePacks: createDefaultScenePacks(),
|
_scenePacks: createDefaultScenePacks(),
|
||||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||||
viewer,
|
viewer,
|
||||||
_sceneSeekGeneration: 0,
|
_sceneSeekGeneration: 0,
|
||||||
_loadGeneration: 0,
|
_loadGeneration: 0,
|
||||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
_cancelActiveSceneTravel:
|
||||||
|
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
layer.setParams({
|
||||||
|
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
|
});
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
if (phase === 'move-end') moveStart.raiseEvent();
|
if (phase === 'move-end') moveStart.raiseEvent();
|
||||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||||
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
|||||||
callbacks[0]();
|
callbacks[0]();
|
||||||
moveEnd.raiseEvent();
|
moveEnd.raiseEvent();
|
||||||
for (const flight of oldFlights) flight.complete();
|
for (const flight of oldFlights) flight.complete();
|
||||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
assert.equal(
|
||||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
flights.length,
|
||||||
|
oldFlights.length,
|
||||||
|
'no post-Stop approach or orbit',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
layer.getStats().count,
|
||||||
|
1,
|
||||||
|
'Stop preserves the visible locator',
|
||||||
|
);
|
||||||
|
|
||||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
layer.setParams({
|
||||||
|
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||||
|
});
|
||||||
callbacks.at(-1)();
|
callbacks.at(-1)();
|
||||||
const successor = flights.at(-1);
|
const successor = flights.at(-1);
|
||||||
const count = flights.length;
|
const count = flights.length;
|
||||||
callbacks[0]();
|
callbacks[0]();
|
||||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
for (const flight of oldFlights) {
|
||||||
|
flight.cancel();
|
||||||
|
flight.complete();
|
||||||
|
}
|
||||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||||
successor.complete();
|
successor.complete();
|
||||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
assert.equal(
|
||||||
} finally { await layer.destroy(); }
|
flights.length,
|
||||||
|
count + 1,
|
||||||
|
'successor retains its own orbit',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await layer.destroy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
|||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => ({
|
boundaryResolver: async () => ({
|
||||||
name: 'Nepal',
|
name: 'Nepal',
|
||||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
overlayHost,
|
overlayHost,
|
||||||
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
|||||||
const source = viewer._test.added[0];
|
const source = viewer._test.added[0];
|
||||||
const borderFill = source.entities.getById('nepal-border-fill');
|
const borderFill = source.entities.getById('nepal-border-fill');
|
||||||
const entries = overlayHost._test.publications.at(-1).entries;
|
const entries = overlayHost._test.publications.at(-1).entries;
|
||||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
const incident = entries.find(
|
||||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
);
|
||||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
const kathmandu = entries.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-city-kathmandu',
|
||||||
|
);
|
||||||
|
const bhaktapur = entries.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-city-bhaktapur',
|
||||||
|
);
|
||||||
|
const dhulikhel = entries.find(
|
||||||
|
({ id }) => id === 'bhote-koshi-city-dhulikhel',
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(borderFill.show.getValue(), true);
|
assert.equal(borderFill.show.getValue(), true);
|
||||||
assert.equal(incident.contentAlpha(), 1);
|
assert.equal(incident.contentAlpha(), 1);
|
||||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
assert.ok(
|
||||||
variant === 'label' && anchorDot === true
|
[kathmandu, bhaktapur, dhulikhel].every(
|
||||||
)));
|
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||||
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
|||||||
nowMs = 1300;
|
nowMs = 1300;
|
||||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||||
|
});
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await layer.enable(viewer);
|
await layer.enable(viewer);
|
||||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
const ids = viewer._test.added[0].entities.values.map(
|
||||||
|
(entity) => entity.id,
|
||||||
|
);
|
||||||
assert.ok(ids.includes('nepal-context-halo'));
|
assert.ok(ids.includes('nepal-context-halo'));
|
||||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||||
'bhote-koshi-incident-callout',
|
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||||
]);
|
);
|
||||||
assert.equal(layer.getStats().status, 'degraded');
|
assert.equal(layer.getStats().status, 'degraded');
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = originalWarn;
|
console.warn = originalWarn;
|
||||||
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
|||||||
const viewer = viewerFixture();
|
const viewer = viewerFixture();
|
||||||
const overlayHost = overlayHostFixture();
|
const overlayHost = overlayHostFixture();
|
||||||
let releaseBoundary;
|
let releaseBoundary;
|
||||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
const boundary = new Promise((resolve) => {
|
||||||
|
releaseBoundary = resolve;
|
||||||
|
});
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => boundary,
|
boundaryResolver: async () => boundary,
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
|||||||
|
|
||||||
const enabling = layer.enable(viewer);
|
const enabling = layer.enable(viewer);
|
||||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
assert.equal(
|
||||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
viewer._test.added[0].entities.getById('nepal-border-highlight'),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
assert.equal(overlayHost._test.publications.length, 1);
|
assert.equal(overlayHost._test.publications.length, 1);
|
||||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
overlayHost._test.publications[0].entries.map(({ id }) => id),
|
||||||
]);
|
['nepal-context-callout'],
|
||||||
|
);
|
||||||
|
|
||||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
releaseBoundary({
|
||||||
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
[80, 30],
|
||||||
|
],
|
||||||
|
});
|
||||||
await enabling;
|
await enabling;
|
||||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
assert.ok(
|
||||||
|
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||||
|
);
|
||||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
assert.equal(
|
||||||
|
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
assert.equal(overlayHost._test.publications.length, 2);
|
assert.equal(overlayHost._test.publications.length, 2);
|
||||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
assert.deepEqual(
|
||||||
'nepal-context-callout',
|
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||||
'bhote-koshi-incident-callout',
|
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||||
]);
|
);
|
||||||
await layer.destroy();
|
await layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
|||||||
const viewer = viewerFixture();
|
const viewer = viewerFixture();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let releaseBoundary;
|
let releaseBoundary;
|
||||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
const boundary = new Promise((resolve) => {
|
||||||
|
releaseBoundary = resolve;
|
||||||
|
});
|
||||||
const layer = createBhoteKoshiLocatorLayer({
|
const layer = createBhoteKoshiLocatorLayer({
|
||||||
boundaryResolver: async () => boundary,
|
boundaryResolver: async () => boundary,
|
||||||
requestRender() {},
|
requestRender() {},
|
||||||
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
|||||||
});
|
});
|
||||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||||
controller.abort();
|
controller.abort();
|
||||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
releaseBoundary({
|
||||||
|
ring: [
|
||||||
|
[80, 26],
|
||||||
|
[88, 26],
|
||||||
|
[88, 30],
|
||||||
|
],
|
||||||
|
});
|
||||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||||
assert.equal(viewer._test.removed.length, 1);
|
assert.equal(viewer._test.removed.length, 1);
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||||
* supplies the actual drawing surface.
|
* supplies the actual drawing surface.
|
||||||
*/
|
*/
|
||||||
|
import { formatNumber, t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||||
const MAX_PRIMARY = 18;
|
const MAX_PRIMARY = 18;
|
||||||
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
* Formats a speed in knots as a short rounded label in the UI language.
|
||||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||||||
|
* vessels).
|
||||||
* @param {number} knots - Speed over ground in knots.
|
* @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) {
|
export function formatKnots(knots) {
|
||||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||||
return Math.round(knots) + ' kn';
|
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);}
|
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');
|
||||||
|
});
|
||||||
@@ -27,6 +27,13 @@ import {
|
|||||||
createApplicationDirectionsLayer,
|
createApplicationDirectionsLayer,
|
||||||
} from './directions.js';
|
} from './directions.js';
|
||||||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
||||||
|
import { instructionFor } from './routeSteps.js';
|
||||||
|
import { setLocale } from '../i18n/index.js';
|
||||||
|
import {
|
||||||
|
directionsInstruction,
|
||||||
|
routeDistanceText,
|
||||||
|
routeDurationText,
|
||||||
|
} from '../layers/directions/index.js';
|
||||||
import {
|
import {
|
||||||
claimPointer,
|
claimPointer,
|
||||||
isPointerFree,
|
isPointerFree,
|
||||||
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
|
|||||||
lastUpdate: 5,
|
lastUpdate: 5,
|
||||||
});
|
});
|
||||||
assert.equal(ready.count, 3);
|
assert.equal(ready.count, 3);
|
||||||
assert.equal(ready.coverage, '21 km · 25 min · Drive');
|
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ready.loadingLabel,
|
ready.loadingLabel,
|
||||||
'21 km · 25 min · Drive',
|
'21\u00a0km · 25\u00a0min · Drive',
|
||||||
'the summary is the row detail line',
|
'the summary is the row detail line',
|
||||||
);
|
);
|
||||||
assert.equal(ready.loading, undefined);
|
assert.equal(ready.loading, undefined);
|
||||||
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
|
|||||||
const copy = directionsStepCopy(route.steps, 1);
|
const copy = directionsStepCopy(route.steps, 1);
|
||||||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||||||
assert.deepEqual(copy.details, [
|
assert.deepEqual(copy.details, [
|
||||||
'Step 2 of 3 · then 240 m · 39 s',
|
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
|
||||||
'Then: Arrive at your destination',
|
'Then: Arrive at your destination',
|
||||||
]);
|
]);
|
||||||
const last = directionsStepCopy(route.steps, 2);
|
const last = directionsStepCopy(route.steps, 2);
|
||||||
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
|
|||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
list.items.map((item) => item.lead),
|
list.items.map((item) => item.lead),
|
||||||
['8 m', '240 m', '0 m'],
|
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
list.items.map((item) => item.params),
|
list.items.map((item) => item.params),
|
||||||
@@ -733,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
|
|||||||
pointerBlockedMessage('measure', 'a'),
|
pointerBlockedMessage('measure', 'a'),
|
||||||
'measure is active — turn measure off, then set 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.
|
// Nothing to say when nothing is in the way, or when we are the holder.
|
||||||
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
assert.equal(pointerBlockedMessage(null, 'a'), null);
|
||||||
assert.equal(pointerBlockedMessage('', '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(toasts.length, 1, 'a successful arming is silent');
|
||||||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
|
||||||
|
const types = [
|
||||||
|
'depart',
|
||||||
|
'arrive',
|
||||||
|
'turn',
|
||||||
|
'new name',
|
||||||
|
'continue',
|
||||||
|
'end of road',
|
||||||
|
'fork',
|
||||||
|
'merge',
|
||||||
|
'on ramp',
|
||||||
|
'off ramp',
|
||||||
|
'roundabout',
|
||||||
|
'rotary',
|
||||||
|
'roundabout turn',
|
||||||
|
'exit roundabout',
|
||||||
|
'exit rotary',
|
||||||
|
'use lane',
|
||||||
|
'notification',
|
||||||
|
'something new',
|
||||||
|
];
|
||||||
|
const modifiers = [
|
||||||
|
null,
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'slight left',
|
||||||
|
'slight right',
|
||||||
|
'sharp left',
|
||||||
|
'sharp right',
|
||||||
|
'straight',
|
||||||
|
'uturn',
|
||||||
|
];
|
||||||
|
const roads = [
|
||||||
|
{ name: '', ref: null },
|
||||||
|
{ name: 'Congress Avenue', ref: null },
|
||||||
|
{ name: 'Mannerheimintie', ref: 'E12' },
|
||||||
|
{ name: '', ref: 'I 35' },
|
||||||
|
];
|
||||||
|
for (const type of types)
|
||||||
|
for (const modifier of modifiers)
|
||||||
|
for (const road of roads)
|
||||||
|
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
|
||||||
|
const step = { type, modifier, exit, ...road };
|
||||||
|
assert.equal(
|
||||||
|
directionsInstruction(step),
|
||||||
|
instructionFor(step),
|
||||||
|
JSON.stringify(step),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// An older payload without a maneuver type keeps the proxy's sentence.
|
||||||
|
assert.equal(
|
||||||
|
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
|
||||||
|
'Head out on Kaivokatu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route numbers follow the page language and never break from their unit', (t) => {
|
||||||
|
t.after(() => setLocale('en'));
|
||||||
|
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||||
|
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||||
|
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||||
|
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||||
|
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||||
|
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||||
|
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||||
|
assert.equal(routeDistanceText(-1), '');
|
||||||
|
assert.equal(routeDurationText(Number.NaN), '');
|
||||||
|
|
||||||
|
setLocale('ru');
|
||||||
|
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
|
||||||
|
assert.equal(routeDurationText(240), '4\u00a0мин');
|
||||||
|
assert.equal(
|
||||||
|
directionsInstruction({
|
||||||
|
type: 'turn',
|
||||||
|
modifier: 'right',
|
||||||
|
name: 'East 7th Street',
|
||||||
|
}),
|
||||||
|
'Поверните направо на East 7th Street',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
|
||||||
|
'На кольце сверните на 2-й съезд',
|
||||||
|
);
|
||||||
|
const list = directionsStepList({ route });
|
||||||
|
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||||
|
});
|
||||||
+92
-34
@@ -2,6 +2,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createImportImageBudget } from './geoImportImages.js';
|
import { createImportImageBudget } from './geoImportImages.js';
|
||||||
import { createImportedPins } from './geoImportPins.js';
|
import { createImportedPins } from './geoImportPins.js';
|
||||||
|
import { msg } from '../i18n/marker.js';
|
||||||
|
|
||||||
/** Largest file accepted, in bytes. */
|
/** Largest file accepted, in bytes. */
|
||||||
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
||||||
@@ -61,11 +62,17 @@ const KML_REMOVED_ELEMENTS = new Set([
|
|||||||
'httpQuery',
|
'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 {
|
export class GeoImportError extends Error {
|
||||||
constructor(message) {
|
constructor(message, template = message, values = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'GeoImportError';
|
this.name = 'GeoImportError';
|
||||||
|
this.template = template;
|
||||||
|
this.values = values;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,12 +131,14 @@ export function parseGeoJsonText(text) {
|
|||||||
try {
|
try {
|
||||||
value = JSON.parse(String(text).replace(/^/, ''));
|
value = JSON.parse(String(text).replace(/^/, ''));
|
||||||
} catch {
|
} 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))
|
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))
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +173,7 @@ export function countGeoJsonGeometry(
|
|||||||
for (let i = 0; i < list.length; i++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
if (list[i]?.type !== 'Feature')
|
if (list[i]?.type !== 'Feature')
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
'Every GeoJSON feature must have type Feature.',
|
msg('Every GeoJSON feature must have type Feature.'),
|
||||||
);
|
);
|
||||||
stack.push(list[i]);
|
stack.push(list[i]);
|
||||||
}
|
}
|
||||||
@@ -183,11 +192,11 @@ export function countGeoJsonGeometry(
|
|||||||
if (node.type === 'GeometryCollection') {
|
if (node.type === 'GeometryCollection') {
|
||||||
const list = node.geometries;
|
const list = node.geometries;
|
||||||
if (!Array.isArray(list))
|
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++) {
|
for (let i = 0; i < list.length; i++) {
|
||||||
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
|
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
'That GeoJSON contains an unsupported geometry.',
|
msg('That GeoJSON contains an unsupported geometry.'),
|
||||||
);
|
);
|
||||||
stack.push(list[i]);
|
stack.push(list[i]);
|
||||||
}
|
}
|
||||||
@@ -216,7 +225,9 @@ export function countGeoJsonGeometry(
|
|||||||
while (coords.length) {
|
while (coords.length) {
|
||||||
const [c, remaining] = coords.pop();
|
const [c, remaining] = coords.pop();
|
||||||
if (!Array.isArray(c))
|
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 (remaining === 0) {
|
||||||
if (
|
if (
|
||||||
c.length < 2 ||
|
c.length < 2 ||
|
||||||
@@ -226,7 +237,9 @@ export function countGeoJsonGeometry(
|
|||||||
Math.abs(c[1]) > 90 ||
|
Math.abs(c[1]) > 90 ||
|
||||||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
|
(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;
|
positions += 1;
|
||||||
if (positions > maxPositions) return result('positions');
|
if (positions > maxPositions) return result('positions');
|
||||||
} else
|
} else
|
||||||
@@ -234,7 +247,7 @@ export function countGeoJsonGeometry(
|
|||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
'That GeoJSON contains an unsupported geometry.',
|
msg('That GeoJSON contains an unsupported geometry.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return result();
|
return result();
|
||||||
@@ -245,17 +258,36 @@ function overLimitError({ over, features }) {
|
|||||||
if (over === 'entities')
|
if (over === 'entities')
|
||||||
return new GeoImportError(
|
return new GeoImportError(
|
||||||
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
|
`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')
|
if (over === 'links')
|
||||||
return new GeoImportError(
|
return new GeoImportError(
|
||||||
`That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
|
`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')
|
if (over === 'features')
|
||||||
return new GeoImportError(
|
return new GeoImportError(
|
||||||
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
|
`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(
|
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;
|
total += value.length;
|
||||||
if (total > maxBytes)
|
if (total > maxBytes)
|
||||||
throw new GeoImportError(
|
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);
|
chunks.push(value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
if (error instanceof GeoImportError) throw error;
|
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);
|
const out = new Uint8Array(total);
|
||||||
let at = 0;
|
let at = 0;
|
||||||
@@ -332,7 +366,7 @@ export async function readZipEntries(
|
|||||||
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
|
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
|
||||||
) {
|
) {
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
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);
|
throw new GeoImportError(message);
|
||||||
};
|
};
|
||||||
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
|
// 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);
|
const dirSize = view.getUint32(eocd + 12, true);
|
||||||
let at = view.getUint32(eocd + 16, true);
|
let at = view.getUint32(eocd + 16, true);
|
||||||
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
|
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 entries = new Map();
|
||||||
const names = new TextDecoder();
|
const names = new TextDecoder();
|
||||||
let inflated = 0;
|
let inflated = 0;
|
||||||
@@ -370,9 +404,9 @@ export async function readZipEntries(
|
|||||||
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
|
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
|
||||||
at += 46 + nameLength + extraLength + commentLength;
|
at += 46 + nameLength + extraLength + commentLength;
|
||||||
if (name.endsWith('/')) continue;
|
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)
|
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)
|
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
|
||||||
fail();
|
fail();
|
||||||
const start =
|
const start =
|
||||||
@@ -386,10 +420,13 @@ export async function readZipEntries(
|
|||||||
if (method === 0) contents = data;
|
if (method === 0) contents = data;
|
||||||
else if (method === 8)
|
else if (method === 8)
|
||||||
contents = await inflateRaw(data, maxInflatedBytes - inflated);
|
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;
|
inflated += contents.length;
|
||||||
if (inflated > maxInflatedBytes)
|
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);
|
entries.set(name, contents);
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -765,7 +802,7 @@ export function countKmlGeometry(
|
|||||||
!Number.isFinite(value) ||
|
!Number.isFinite(value) ||
|
||||||
Math.abs(value) > limit
|
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
|
// Cesium creates entities for folders, a parent for every placemark, and
|
||||||
// a child for each geometry in a MultiGeometry, including nested containers.
|
// a child for each geometry in a MultiGeometry, including nested containers.
|
||||||
@@ -822,7 +859,7 @@ function checkKmlPosition(tuple) {
|
|||||||
Math.abs(numbers[1]) > 90 ||
|
Math.abs(numbers[1]) > 90 ||
|
||||||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
|
(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;
|
const root = doc.documentElement;
|
||||||
if (root?.localName !== 'gpx')
|
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 = [];
|
const features = [];
|
||||||
let positions = 0;
|
let positions = 0;
|
||||||
const point = (el) => {
|
const point = (el) => {
|
||||||
@@ -1028,7 +1065,11 @@ function parseXml(text, what) {
|
|||||||
'application/xml',
|
'application/xml',
|
||||||
);
|
);
|
||||||
if (doc.getElementsByTagName('parsererror').length)
|
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;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1107,9 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
|
msg(
|
||||||
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
|
'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) => {
|
new Promise((_, reject) => {
|
||||||
const failed = () => {
|
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);
|
globalThis.addEventListener?.('error', failed);
|
||||||
removeError = () => globalThis.removeEventListener?.('error', failed);
|
removeError = () => globalThis.removeEventListener?.('error', failed);
|
||||||
@@ -1116,7 +1159,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
|
|||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
const cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||||
if (!cartographic)
|
if (!cartographic)
|
||||||
throw new GeoImportError('That file has invalid point coordinates.');
|
throw new GeoImportError(
|
||||||
|
msg('That file has invalid point coordinates.'),
|
||||||
|
);
|
||||||
pins.push({
|
pins.push({
|
||||||
entity,
|
entity,
|
||||||
original: position,
|
original: position,
|
||||||
@@ -1192,7 +1237,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
|
|||||||
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
|
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
|
||||||
if (!c)
|
if (!c)
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
|
msg(
|
||||||
'Ground heights are not available for that file. Try again when the map has loaded.',
|
'Ground heights are not available for that file. Try again when the map has loaded.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
item.groundHeight = c.height;
|
item.groundHeight = c.height;
|
||||||
c.height += item.offset;
|
c.height += item.offset;
|
||||||
@@ -1216,7 +1263,8 @@ async function boundedImport(promise, signal) {
|
|||||||
abort = () => reject(signal.reason);
|
abort = () => reject(signal.reason);
|
||||||
signal?.addEventListener('abort', abort, { once: true });
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
timer = setTimeout(
|
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,
|
60_000,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -1244,16 +1292,18 @@ export async function loadGeoFile(
|
|||||||
: AbortSignal.timeout(60_000);
|
: AbortSignal.timeout(60_000);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!file || !Number.isFinite(file.size))
|
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)
|
if (file.size > MAX_IMPORT_BYTES)
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
|
`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));
|
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
|
||||||
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
|
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
|
||||||
if (!format)
|
if (!format)
|
||||||
throw new GeoImportError(
|
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 stroke = Cesium.Color.fromCssColorString(color);
|
||||||
const urls = [];
|
const urls = [];
|
||||||
@@ -1276,7 +1326,7 @@ export async function loadGeoFile(
|
|||||||
const size = countGeoJsonGeometry(geojson);
|
const size = countGeoJsonGeometry(geojson);
|
||||||
if (size.over) throw overLimitError(size);
|
if (size.over) throw overLimitError(size);
|
||||||
if (!size.positions)
|
if (!size.positions)
|
||||||
throw new GeoImportError('That file has nothing to draw.');
|
throw new GeoImportError(msg('That file has nothing to draw.'));
|
||||||
prepareGeoJson(geojson);
|
prepareGeoJson(geojson);
|
||||||
bytes = null;
|
bytes = null;
|
||||||
dataSource = await boundedImport(
|
dataSource = await boundedImport(
|
||||||
@@ -1299,7 +1349,8 @@ export async function loadGeoFile(
|
|||||||
if (format === 'kmz') {
|
if (format === 'kmz') {
|
||||||
entries = await readZipEntries(bytes);
|
entries = await readZipEntries(bytes);
|
||||||
main = kmzMainDocument(entries);
|
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));
|
text = decodeXml(entries.get(main));
|
||||||
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
|
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
|
||||||
} else {
|
} else {
|
||||||
@@ -1325,6 +1376,8 @@ export async function loadGeoFile(
|
|||||||
if (error instanceof GeoImportError) throw error;
|
if (error instanceof GeoImportError) throw error;
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
`That ${format.toUpperCase()} file could not be read.`,
|
`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;
|
const entityCount = dataSource.entities.values.length;
|
||||||
@@ -1332,6 +1385,11 @@ export async function loadGeoFile(
|
|||||||
revoke();
|
revoke();
|
||||||
throw new GeoImportError(
|
throw new GeoImportError(
|
||||||
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
|
`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();
|
const now = Cesium.JulianDate.now();
|
||||||
@@ -1346,7 +1404,7 @@ export async function loadGeoFile(
|
|||||||
);
|
);
|
||||||
if (!drawable) {
|
if (!drawable) {
|
||||||
revoke();
|
revoke();
|
||||||
throw new GeoImportError('That file has nothing to draw.');
|
throw new GeoImportError(msg('That file has nothing to draw.'));
|
||||||
}
|
}
|
||||||
scrubImportedEntities(dataSource);
|
scrubImportedEntities(dataSource);
|
||||||
try {
|
try {
|
||||||
@@ -1356,7 +1414,7 @@ export async function loadGeoFile(
|
|||||||
throw error instanceof GeoImportError
|
throw error instanceof GeoImportError
|
||||||
? error
|
? error
|
||||||
: new GeoImportError(
|
: 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
|
// Keep the clock invariant explicit even though unused KML dates have
|
||||||
@@ -1366,7 +1424,7 @@ export async function loadGeoFile(
|
|||||||
pins = createImportedPins(dataSource, viewer);
|
pins = createImportedPins(dataSource, viewer);
|
||||||
} catch {
|
} catch {
|
||||||
revoke();
|
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
|
// The display name travels beside the data source (the file name, not
|
||||||
// anything the document says about itself).
|
// anything the document says about itself).
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
/** Explain mapped-site availability without claiming an unobserved overload. */
|
import { msg } from '../i18n/marker.js';
|
||||||
export function installationFeedback(stats = {}, now = Date.now()) {
|
|
||||||
const reasons = {
|
// layers/installations/controls.js translates describeInstallationFeedback().
|
||||||
rate_limited: 'Overpass rate-limited',
|
const REASONS = Object.freeze({
|
||||||
timeout: 'Overpass timed out',
|
rate_limited: msg('Overpass rate-limited'),
|
||||||
query_failed: 'Overpass could not complete the query',
|
timeout: msg('Overpass timed out'),
|
||||||
tiles_unavailable: 'Map tiles temporarily unavailable',
|
query_failed: msg('Overpass could not complete the query'),
|
||||||
names_unavailable: 'Mapped names temporarily unavailable',
|
tiles_unavailable: msg('Map tiles temporarily unavailable'),
|
||||||
};
|
names_unavailable: msg('Mapped names temporarily unavailable'),
|
||||||
const reason =
|
});
|
||||||
reasons[stats.failureReason] || 'Overpass 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)
|
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) {
|
if (stats.retryAt > 0) {
|
||||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
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')
|
if (stats.status === 'zoom-in')
|
||||||
return 'Zoom in to search mapped installations';
|
return { text: msg('Zoom in to search mapped installations') };
|
||||||
if (stats.stale) return 'Showing cached mapped sites';
|
if (stats.stale) return { text: msg('Showing cached mapped sites') };
|
||||||
if (stats.status === 'idle') return 'Mapped sites not loaded';
|
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".
|
// With a count, say what was found and where; an empty area is not "loaded".
|
||||||
if (Number.isFinite(stats.count)) {
|
if (Number.isFinite(stats.count)) {
|
||||||
const km =
|
const km =
|
||||||
@@ -27,9 +45,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
|||||||
Number.isFinite(stats.coverage.radiusM)
|
Number.isFinite(stats.coverage.radiusM)
|
||||||
? Math.round(stats.coverage.radiusM / 1000)
|
? Math.round(stats.coverage.radiusM / 1000)
|
||||||
: null;
|
: null;
|
||||||
const where = km ? ` within ${km} km of the contact` : ' in view';
|
return { count: stats.count, km: km || null };
|
||||||
if (stats.count === 0) return `No mapped sites${where}`;
|
|
||||||
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
|
|
||||||
}
|
}
|
||||||
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}`;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -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 { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
@@ -473,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
|||||||
optionOwner: 'flights',
|
optionOwner: 'flights',
|
||||||
optionKey: 'selectedFlightsTrackingId',
|
optionKey: 'selectedFlightsTrackingId',
|
||||||
expiryWindowMs: 90_000,
|
expiryWindowMs: 90_000,
|
||||||
label: 'flight',
|
label: msg('flight'),
|
||||||
}),
|
}),
|
||||||
military: Object.freeze({
|
military: Object.freeze({
|
||||||
optionOwner: 'flights',
|
optionOwner: 'flights',
|
||||||
optionKey: 'selectedMilitaryTrackingId',
|
optionKey: 'selectedMilitaryTrackingId',
|
||||||
expiryWindowMs: 45_000,
|
expiryWindowMs: 45_000,
|
||||||
label: 'military flight',
|
label: msg('military flight'),
|
||||||
}),
|
}),
|
||||||
satellites: Object.freeze({
|
satellites: Object.freeze({
|
||||||
optionOwner: 'satellites',
|
optionOwner: 'satellites',
|
||||||
optionKey: 'selectedSatTrackingId',
|
optionKey: 'selectedSatTrackingId',
|
||||||
expiryWindowMs: 300_000,
|
expiryWindowMs: 300_000,
|
||||||
label: 'satellite',
|
label: msg('satellite'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1500
-404
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,10 @@
|
|||||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
/** 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_RADIUS_M = 250000;
|
||||||
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
|
||||||
export const AWARENESS_RELATIONSHIP = Object.freeze({
|
export const AWARENESS_RELATIONSHIP = Object.freeze({
|
||||||
@@ -47,7 +52,11 @@ export function formatAwarenessDistance(meters) {
|
|||||||
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
|
: `${(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) {
|
export function formatAwarenessLabel(value) {
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
for (const candidate of [
|
for (const candidate of [
|
||||||
@@ -63,7 +72,11 @@ export function formatAwarenessLabel(value) {
|
|||||||
}
|
}
|
||||||
return '—';
|
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);
|
if (Number.isFinite(value)) return String(value);
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
|
|||||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
count: null,
|
count: null,
|
||||||
nearest: [],
|
nearest: [],
|
||||||
reason: 'feed unavailable',
|
reason: t('feed unavailable'),
|
||||||
};
|
};
|
||||||
if (stale)
|
if (stale)
|
||||||
return {
|
return {
|
||||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||||
count: null,
|
count: null,
|
||||||
nearest: [],
|
nearest: [],
|
||||||
reason: 'feed stale',
|
reason: t('feed stale'),
|
||||||
};
|
};
|
||||||
const normalized = (Array.isArray(items) ? items : [])
|
const normalized = (Array.isArray(items) ? items : [])
|
||||||
.filter(
|
.filter(
|
||||||
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
|
|||||||
count: normalized.length,
|
count: normalized.length,
|
||||||
nearest: normalized.slice(0, limit),
|
nearest: normalized.slice(0, limit),
|
||||||
reason: normalized.length
|
reason: normalized.length
|
||||||
? 'observed or mapped nearby context'
|
? t('observed or mapped nearby context')
|
||||||
: 'no observed or mapped objects in current feeds',
|
: t('no observed or mapped objects in current feeds'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Normalize the deliberately small, allow-listed installation context returned
|
||||||
* by `/api/military-installations`. These are mapped features, not assertions
|
* 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
|
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
|
||||||
* attribution and the details panel read it.
|
* attribution and the details panel read it.
|
||||||
*/
|
*/
|
||||||
const LABEL_BY_CLASS = {
|
// The labels stay English in data (the server provider and AI tools read the
|
||||||
airfield: 'Military airfield',
|
// records too); the map card and Contacts translate them (installationNames.js).
|
||||||
naval_base: 'Naval base',
|
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
|
||||||
range: 'Firing range',
|
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
|
||||||
military_land: 'Military land',
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_FOOTPRINT_POINTS = 400;
|
const MAX_FOOTPRINT_POINTS = 400;
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ export function humanizeInstallationClass(klass) {
|
|||||||
const words = key.replaceAll('_', ' ').trim();
|
const words = key.replaceAll('_', ' ').trim();
|
||||||
return words
|
return words
|
||||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||||
: 'Mapped installation';
|
: UNNAMED_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finiteLatitude(value) {
|
function finiteLatitude(value) {
|
||||||
|
|||||||
@@ -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
@@ -17,54 +17,46 @@
|
|||||||
* for), then the constellations users can actually name, then the grab-bag.
|
* 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,
|
* Class registry: the point color of each class. The card and legend text is
|
||||||
* `blurb` the plain-language gloss for the legend tooltip.
|
* in CLASS_DISPLAY_KEYS.
|
||||||
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
|
* @type {Readonly<Record<string, { color: string }>>}
|
||||||
*/
|
*/
|
||||||
export const SATELLITE_CLASSES = Object.freeze({
|
export const SATELLITE_CLASSES = Object.freeze({
|
||||||
station: Object.freeze({
|
station: Object.freeze({
|
||||||
label: 'STATION',
|
|
||||||
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||||
// leak into another domain. Slightly warm so it also separates from the
|
// leak into another domain. Slightly warm so it also separates from the
|
||||||
// pure white the civilian air fleet draws in.
|
// pure white the civilian air fleet draws in.
|
||||||
color: '#fff6e5',
|
color: '#fff6e5',
|
||||||
blurb: 'Crewed stations and their visiting vehicles',
|
|
||||||
}),
|
}),
|
||||||
nav: Object.freeze({
|
nav: Object.freeze({
|
||||||
label: 'NAV',
|
|
||||||
// Cyan — the strongest accent goes to the class users actually name
|
// Cyan — the strongest accent goes to the class users actually name
|
||||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
||||||
// VISUAL group used to occupy, so this adds no new hue to the app.
|
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||||
color: '#4fd8ff',
|
color: '#4fd8ff',
|
||||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
|
||||||
}),
|
}),
|
||||||
geo: Object.freeze({
|
geo: Object.freeze({
|
||||||
label: 'GEO',
|
|
||||||
// Violet, unchanged. Already the app's "space" semantic (the detection
|
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||||
// as one clean equatorial ring at globe scale.
|
// as one clean equatorial ring at globe scale.
|
||||||
color: '#c89bff',
|
color: '#c89bff',
|
||||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
|
|
||||||
}),
|
}),
|
||||||
visual: Object.freeze({
|
visual: Object.freeze({
|
||||||
label: 'VISUAL',
|
|
||||||
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
||||||
// cyan it used to hold and recedes behind the three classes that mean
|
// cyan it used to hold and recedes behind the three classes that mean
|
||||||
// something. Still well clear of the dense shell by luminance.
|
// something. Still well clear of the dense shell by luminance.
|
||||||
color: '#9fb3c4',
|
color: '#9fb3c4',
|
||||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
|
|
||||||
}),
|
}),
|
||||||
comms: Object.freeze({
|
comms: Object.freeze({
|
||||||
label: 'COMMS',
|
|
||||||
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
||||||
// texture behind the core catalog, never compete with it. Rec.601 luma
|
// texture behind the core catalog, never compete with it. Rec.601 luma
|
||||||
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||||
color: '#54697f',
|
color: '#54697f',
|
||||||
blurb: 'Broadband constellation shell — shown only in DENSE mode',
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
|
|||||||
'comms',
|
'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
|
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||||
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
|
* 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
|
* The ISS is always a STATION regardless of which group ingested it — see
|
||||||
* satelliteClassOf, which owns that rule for every consumer.
|
* satelliteClassOf, which owns that rule for every consumer.
|
||||||
* @param {string|undefined|null} group CelesTrak group tag.
|
* @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.
|
* @returns {string} Display label.
|
||||||
*/
|
*/
|
||||||
export function satelliteClassLabel(group, { isIss = false } = {}) {
|
export function satelliteClassLabel(
|
||||||
|
group,
|
||||||
|
{ isIss = false, localize = true } = {},
|
||||||
|
) {
|
||||||
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
||||||
const base = SATELLITE_CLASSES[klass].label;
|
const label = CLASS_DISPLAY_KEYS[klass].label;
|
||||||
|
const base = localize ? t(label) : label;
|
||||||
return subtype ? `${base} · ${subtype}` : base;
|
return subtype ? `${base} · ${subtype}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
|
|||||||
const count = counts?.[klass];
|
const count = counts?.[klass];
|
||||||
if (!(count > 0)) continue;
|
if (!(count > 0)) continue;
|
||||||
const spec = SATELLITE_CLASSES[klass];
|
const spec = SATELLITE_CLASSES[klass];
|
||||||
|
const display = CLASS_DISPLAY_KEYS[klass];
|
||||||
result.push({
|
result.push({
|
||||||
klass,
|
klass,
|
||||||
label: spec.label,
|
label: t(display.label),
|
||||||
color: spec.color,
|
color: spec.color,
|
||||||
blurb: spec.blurb,
|
blurb: t(display.blurb),
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
import satellitesLayer from './satellites.js';
|
import satellitesLayer from './satellites.js';
|
||||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||||
|
|
||||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
const L1 =
|
||||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
'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 () => {
|
test('partial and dense catalogs do not make an early false missing decision', async () => {
|
||||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
_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/);
|
assert.match(partial.reason, /partial CelesTrak catalog/);
|
||||||
|
|
||||||
let releaseDense;
|
let releaseDense;
|
||||||
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
|
const densePromise = new Promise((resolve) => {
|
||||||
|
releaseDense = resolve;
|
||||||
|
});
|
||||||
_setSatelliteTrackingRefreshOutcomeForTest({
|
_setSatelliteTrackingRefreshOutcomeForTest({
|
||||||
status: 'accepted',
|
status: 'accepted',
|
||||||
catalog: 'dense',
|
catalog: 'dense',
|
||||||
densePromise,
|
densePromise,
|
||||||
});
|
});
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
|
const resolution = satellitesLayer
|
||||||
.then((result) => { settled = true; return result; });
|
.resolveTrackingRestoreTarget(987654)
|
||||||
|
.then((result) => {
|
||||||
|
settled = true;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
|
||||||
releaseDense({ status: 'ready' });
|
releaseDense({ status: 'ready' });
|
||||||
assert.equal((await resolution).status, 'missing');
|
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 () => {
|
test('satellite first update aborts every parallel catalog request', async () => {
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
const observedSignals = [];
|
const observedSignals = [];
|
||||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
globalThis.fetch = (_url, options = {}) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
observedSignals.push(options.signal);
|
observedSignals.push(options.signal);
|
||||||
options.signal?.addEventListener('abort', () => {
|
options.signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
reject(new DOMException('aborted', 'AbortError'));
|
reject(new DOMException('aborted', 'AbortError'));
|
||||||
}, { once: true });
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
|
|||||||
assert.ok(observedSignals.length > 1);
|
assert.ok(observedSignals.length > 1);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
await assert.rejects(work, { name: 'AbortError' });
|
await assert.rejects(work, { name: 'AbortError' });
|
||||||
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
|
assert.equal(
|
||||||
|
observedSignals.every((signal) => signal?.aborted),
|
||||||
|
true,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
}
|
}
|
||||||
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
|||||||
point,
|
point,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
|
assert.doesNotThrow(() =>
|
||||||
|
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||||
|
);
|
||||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites: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(_pendingSatelliteTrackingRestoreForTest(), 99999);
|
||||||
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
|
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();
|
_applyPendingSatelliteTrackingRestoreForTest();
|
||||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
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);
|
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||||
satellitesLayer.stopTracking({ origin: 'user' });
|
satellitesLayer.stopTracking({ origin: 'user' });
|
||||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
assert.equal(
|
||||||
|
_pendingSatelliteTrackingRestoreForTest(),
|
||||||
|
null,
|
||||||
|
'explicit clear cancels stale restore',
|
||||||
|
);
|
||||||
_setSatelliteLabelLifecycleStateForTest({
|
_setSatelliteLabelLifecycleStateForTest({
|
||||||
viewer,
|
viewer,
|
||||||
satrec: twoline2satrec(L1, L2),
|
satrec: twoline2satrec(L1, L2),
|
||||||
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
|||||||
const details = entity.gevLabelModel.details;
|
const details = entity.gevLabelModel.details;
|
||||||
// Class leads the detail block; the altitude line follows it, and the
|
// Class leads the detail block; the altitude line follows it, and the
|
||||||
// consolidated companions stay last.
|
// 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.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
|
||||||
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
|
assert.equal(
|
||||||
'companions are consolidated, named, and counted on the tracked card');
|
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
|
// 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
|
// 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,
|
camera: null,
|
||||||
scene: { frameState: { frameNumber: 1 } },
|
scene: { frameState: { frameNumber: 1 } },
|
||||||
};
|
};
|
||||||
const epochs = [
|
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||||
Date.UTC(2008, 8, 20, 12, 30),
|
|
||||||
Date.UTC(2008, 8, 20, 13, 0),
|
|
||||||
];
|
|
||||||
let epochIndex = 0;
|
let epochIndex = 0;
|
||||||
_setTrackedSatelliteRefreshStateForTest({
|
_setTrackedSatelliteRefreshStateForTest({
|
||||||
noradId: 25544,
|
noradId: 25544,
|
||||||
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
|||||||
_runSatellitePreRenderForTest();
|
_runSatellitePreRenderForTest();
|
||||||
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
|
||||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
|
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;
|
epochIndex = 1;
|
||||||
viewer.scene.frameState.frameNumber = 2;
|
viewer.scene.frameState.frameNumber = 2;
|
||||||
_runSatellitePreRenderForTest();
|
_runSatellitePreRenderForTest();
|
||||||
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
|
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||||
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
|
assert.equal(
|
||||||
'the class line survives an altitude-only republish');
|
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', () => {
|
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);
|
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
|
||||||
const ambient = ambientPublication[2][0];
|
const ambient = ambientPublication[2][0];
|
||||||
assert.equal(ambient.title, 'ISS');
|
assert.equal(ambient.title, 'ISS');
|
||||||
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
|
assert.equal(
|
||||||
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
|
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();
|
const trackedEntity = _trackIssForTest();
|
||||||
assert.ok(trackedEntity);
|
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.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);
|
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
|
||||||
assert.ok(trackedEntry);
|
assert.ok(trackedEntry);
|
||||||
assert.equal(trackedEntry.id, 'satellites:25544');
|
assert.equal(trackedEntry.id, 'satellites:25544');
|
||||||
assert.equal(trackedEntry.protected, true);
|
assert.equal(trackedEntry.protected, true);
|
||||||
assert.equal(trackedEntry.paintLane, 'tracked');
|
assert.equal(trackedEntry.paintLane, 'tracked');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
calls.filter(
|
||||||
|
([type, sourceId]) =>
|
||||||
|
type === 'entries' && sourceId === 'satellites-iss',
|
||||||
|
).length,
|
||||||
2,
|
2,
|
||||||
'tracking may refresh before ownership changes but must not publish a second ISS entry',
|
'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'],
|
['clear', 'satellites-iss'],
|
||||||
['visible', 'satellites-iss', false],
|
['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();
|
_clearSatelliteLabelLifecycleForTest();
|
||||||
cleaned = true;
|
cleaned = true;
|
||||||
assert.equal(viewer.entities.values.length, 0);
|
assert.equal(viewer.entities.values.length, 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
|
calls.filter(
|
||||||
|
([type, sourceId]) =>
|
||||||
|
type === 'entries' && sourceId === 'satellites-iss',
|
||||||
|
).length,
|
||||||
3,
|
3,
|
||||||
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
'untracking republishes exactly one persistent ISS ambient entry before teardown',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* after the vessel cards moved out of their dedicated canvas renderer.
|
* 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';
|
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
|
||||||
/** Existing selector grid size; one ambient winner is retained per cell. */
|
/** Existing selector grid size; one ambient winner is retained per cell. */
|
||||||
export const VESSEL_LABEL_GRID_PX = 118;
|
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;
|
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
|
||||||
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
|
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
|
||||||
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
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
|
* AIS type family → chevron hue + card accent. Single source of truth for
|
||||||
@@ -64,6 +68,42 @@ const NUMERIC_TYPE_FAMILIES = {
|
|||||||
9: 'OTHER',
|
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
|
* Resolve an AIS type to display text: bare numeric ship-type codes map to
|
||||||
* family names ("71" → "CARGO"); text types pass through unchanged.
|
* family names ("71" → "CARGO"); text types pass through unchanged.
|
||||||
@@ -148,7 +188,7 @@ export function applyVesselOverlayPolicy(
|
|||||||
verticalOnly: !selected,
|
verticalOnly: !selected,
|
||||||
viewportMargin: 4,
|
viewportMargin: 4,
|
||||||
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
|
||||||
distanceFadeStartRatio: 0.7,
|
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
|
||||||
edgeFade: 'keyhole',
|
edgeFade: 'keyhole',
|
||||||
horizonCull: true,
|
horizonCull: true,
|
||||||
terrainOcclusion: false,
|
terrainOcclusion: false,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ export class SceneDocumentError extends Error {
|
|||||||
super(`${path}: ${reason}`);
|
super(`${path}: ${reason}`);
|
||||||
this.name = 'SceneDocumentError';
|
this.name = 'SceneDocumentError';
|
||||||
this.path = path;
|
this.path = path;
|
||||||
|
this.reason = reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const fail = (path, reason) => {
|
export const fail = (path, reason) => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { msg } from '../../i18n/marker.js';
|
||||||
import { BUNDLE_SOURCE } from './bundle.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. */
|
/** Describe an import without fetching assets, applying state or exposing source configuration. */
|
||||||
export function describeSceneShare(
|
export function describeSceneShare(
|
||||||
{ project, assets },
|
{ project, assets },
|
||||||
@@ -16,11 +19,11 @@ export function describeSceneShare(
|
|||||||
status:
|
status:
|
||||||
pack.source.adapter === BUNDLE_SOURCE
|
pack.source.adapter === BUNDLE_SOURCE
|
||||||
? assets.has(pack.source.path)
|
? assets.has(pack.source.path)
|
||||||
? 'Included in bundle'
|
? msg('Included in bundle')
|
||||||
: 'Missing bundle file — reimport its bundle'
|
: msg('Missing bundle file — reimport its bundle')
|
||||||
: sources.has(pack.source.adapter)
|
: sources.has(pack.source.adapter)
|
||||||
? 'Source configured; file checked when loaded'
|
? msg('Source configured; file checked when loaded')
|
||||||
: 'Source unavailable',
|
: msg('Source unavailable'),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+36
-13
@@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
|||||||
// Choosing a mission is deliberately NOT durable suppression: picking a mission
|
// Choosing a mission is deliberately NOT durable suppression: picking a mission
|
||||||
// is enthusiasm, not "never show me this again".
|
// 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. */
|
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
|
||||||
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
|
||||||
/** Per-session dismissal. Written by every close path; scoped to sessionStorage. */
|
/** 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';
|
export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
|
||||||
|
|
||||||
const ENVIRONMENTAL_LABELS = Object.freeze({
|
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
|
||||||
ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }),
|
ENVIRONMENTAL: msg('ENVIRONMENTAL'),
|
||||||
EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }),
|
EARTH_WATCH: msg('EARTH WATCH'),
|
||||||
ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }),
|
ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +47,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({
|
|||||||
* @returns {{title: string}} The label set the constant above selects.
|
* @returns {{title: string}} The label set the constant above selects.
|
||||||
*/
|
*/
|
||||||
export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) {
|
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({
|
contacts: Object.freeze({
|
||||||
kind: 'context',
|
kind: 'context',
|
||||||
contextMode: 'contacts',
|
contextMode: 'contacts',
|
||||||
busyText: 'Starting live contacts…',
|
busyKey: msg('Starting live contacts…'),
|
||||||
}),
|
}),
|
||||||
'space-missions': Object.freeze({
|
'space-missions': Object.freeze({
|
||||||
kind: 'context',
|
kind: 'context',
|
||||||
contextMode: 'space-missions',
|
contextMode: 'space-missions',
|
||||||
busyText: 'Opening space missions…',
|
busyKey: msg('Opening space missions…'),
|
||||||
}),
|
}),
|
||||||
environmental: Object.freeze({
|
environmental: Object.freeze({
|
||||||
kind: 'globe',
|
kind: 'globe',
|
||||||
@@ -115,7 +119,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
|
|||||||
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
|
||||||
// are judged on the layer row, which tells them the truth.
|
// are judged on the layer row, which tells them the truth.
|
||||||
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
layerIds: Object.freeze(['earthquakes', 'local-firms']),
|
||||||
busyText: 'Scanning active events…',
|
busyKey: msg('Scanning active events…'),
|
||||||
}),
|
}),
|
||||||
explore: Object.freeze({ kind: 'none' }),
|
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
|
// 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(
|
const environmentalTitle = root.querySelector(
|
||||||
'[data-first-run-environmental-title]',
|
'[data-first-run-environmental-title]',
|
||||||
);
|
);
|
||||||
if (environmentalTitle)
|
if (environmentalTitle)
|
||||||
environmentalTitle.textContent = environmentalLabel().title;
|
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 status = root.querySelector('[data-first-run-status]');
|
||||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||||
@@ -443,7 +460,9 @@ export function initFirstRunExperience({
|
|||||||
button.setAttribute('aria-disabled', String(next));
|
button.setAttribute('aria-disabled', String(next));
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
if (next)
|
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')
|
else if (status.dataset.sticky !== 'true')
|
||||||
status.textContent = defaultStatus;
|
status.textContent = defaultStatus;
|
||||||
};
|
};
|
||||||
@@ -493,7 +512,10 @@ export function initFirstRunExperience({
|
|||||||
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
|
||||||
if (status) {
|
if (status) {
|
||||||
status.dataset.sticky = 'true';
|
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);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
@@ -509,8 +531,9 @@ export function initFirstRunExperience({
|
|||||||
if (box) box.checked = !wanted;
|
if (box) box.checked = !wanted;
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
status.dataset.sticky = 'true';
|
status.dataset.sticky = 'true';
|
||||||
status.textContent =
|
status.textContent = t(
|
||||||
'This browser is blocking storage, so that could not be saved.';
|
'This browser is blocking storage, so that could not be saved.',
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyboard = createSurfaceKeyboard({
|
const keyboard = createSurfaceKeyboard({
|
||||||
|
|||||||
+471
-132
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
|
|||||||
rememberFirstRunSessionDismissed(session);
|
rememberFirstRunSessionDismissed(session);
|
||||||
assert.equal(session.read(), 'dismissed');
|
assert.equal(session.read(), 'dismissed');
|
||||||
// Gone for THIS session...
|
// Gone for THIS session...
|
||||||
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
|
assert.equal(
|
||||||
|
shouldShowFirstRun({
|
||||||
|
storage,
|
||||||
|
sessionStorageRef: session,
|
||||||
|
location: { search: '' },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
// ...and back in the next one, because sessionStorage did not survive it.
|
// ...and back in the next one, because sessionStorage did not survive it.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
|
shouldShowFirstRun({
|
||||||
storage,
|
storage,
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
location: { search: '' },
|
location: { search: '' },
|
||||||
}), true);
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
// Session dismissal must never have written the durable key.
|
// Session dismissal must never have written the durable key.
|
||||||
assert.equal(storage.read(), null);
|
assert.equal(storage.read(), null);
|
||||||
});
|
});
|
||||||
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
|
|||||||
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
||||||
setFirstRunSuppressed(true, storage);
|
setFirstRunSuppressed(true, storage);
|
||||||
assert.equal(storage.read(), 'suppressed');
|
assert.equal(storage.read(), 'suppressed');
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
|
shouldShowFirstRun({
|
||||||
storage,
|
storage,
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
location: { search: '' },
|
location: { search: '' },
|
||||||
}), false);
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Unticking before dismissing takes the suppression back.
|
// Unticking before dismissing takes the suppression back.
|
||||||
setFirstRunSuppressed(false, storage);
|
setFirstRunSuppressed(false, storage);
|
||||||
assert.equal(storage.read(), null);
|
assert.equal(storage.read(), null);
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
|
shouldShowFirstRun({
|
||||||
storage,
|
storage,
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
location: { search: '' },
|
location: { search: '' },
|
||||||
}), true);
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
||||||
setFirstRunSuppressed(true, storage);
|
setFirstRunSuppressed(true, storage);
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
|
shouldShowFirstRun({
|
||||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||||
location: { search: '' },
|
location: { search: '' },
|
||||||
}), true);
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('welcome params work in both directions and outrank both suppressions', () => {
|
test('welcome params work in both directions and outrank both suppressions', () => {
|
||||||
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
||||||
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
||||||
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
|
shouldShowFirstRun({
|
||||||
}), true);
|
storage: suppressed,
|
||||||
|
sessionStorageRef: dismissed,
|
||||||
|
location: { search: '?welcome=1' },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
// ?welcome=0 suppresses a session that would otherwise see it.
|
// ?welcome=0 suppresses a session that would otherwise see it.
|
||||||
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
|
assert.equal(
|
||||||
|
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
// A share link outranks everything, including the replay hatch.
|
// A share link outranks everything, including the replay hatch.
|
||||||
assert.equal(shouldShowFirstRun({
|
assert.equal(
|
||||||
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
|
shouldShowFirstRun({
|
||||||
}), false);
|
hasShareState: true,
|
||||||
|
...fresh(),
|
||||||
|
location: { search: '?welcome=1' },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a share link never sees the launcher — its author already chose the view', () => {
|
test('a share link never sees the launcher — its author already chose the view', () => {
|
||||||
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
|
|||||||
|
|
||||||
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
||||||
const blocked = {
|
const blocked = {
|
||||||
getItem: () => { throw new Error('blocked'); },
|
getItem: () => {
|
||||||
setItem: () => { throw new Error('blocked'); },
|
throw new Error('blocked');
|
||||||
removeItem: () => { throw new Error('blocked'); },
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
|
assert.equal(
|
||||||
|
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
|
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
|
||||||
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
|
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
|
||||||
});
|
});
|
||||||
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
|||||||
// before the function body starts, so the SecurityError escapes every
|
// before the function body starts, so the SecurityError escapes every
|
||||||
// try/catch in the module and the launcher silently never appears.
|
// try/catch in the module and the launcher silently never appears.
|
||||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||||
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
const savedSession = Object.getOwnPropertyDescriptor(
|
||||||
|
globalThis,
|
||||||
|
'sessionStorage',
|
||||||
|
);
|
||||||
const hostile = {
|
const hostile = {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get() { throw new Error('SecurityError: The operation is insecure.'); },
|
get() {
|
||||||
|
throw new Error('SecurityError: The operation is insecure.');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Object.defineProperty(globalThis, 'localStorage', hostile);
|
Object.defineProperty(globalThis, 'localStorage', hostile);
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
||||||
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
|||||||
} finally {
|
} finally {
|
||||||
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
||||||
else delete globalThis.localStorage;
|
else delete globalThis.localStorage;
|
||||||
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
if (savedSession)
|
||||||
|
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||||
else delete globalThis.sessionStorage;
|
else delete globalThis.sessionStorage;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no storage is touched from a default parameter position', () => {
|
test('no storage is touched from a default parameter position', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
// Comments stripped first: the block explaining this very defect quotes the
|
// Comments stripped first: the block explaining this very defect quotes the
|
||||||
// bad pattern, and matching prose instead of code would make the pin a liar.
|
// bad pattern, and matching prose instead of code would make the pin a liar.
|
||||||
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
const code = module
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/^\s*\/\/.*$/gm, '');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
code,
|
code,
|
||||||
/=\s*globalThis\.(local|session)Storage/,
|
/=\s*globalThis\.(local|session)Storage/,
|
||||||
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
|
|||||||
);
|
);
|
||||||
// The only reads of the global areas live inside the guarded resolver.
|
// The only reads of the global areas live inside the guarded resolver.
|
||||||
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
||||||
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
|
assert.equal(
|
||||||
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
|
globalReads.length,
|
||||||
|
2,
|
||||||
|
'exactly two global storage reads, both in resolveStore',
|
||||||
|
);
|
||||||
|
const resolver = code.slice(
|
||||||
|
code.indexOf('function resolveStore'),
|
||||||
|
code.indexOf('function readStored'),
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
||||||
2,
|
2,
|
||||||
'both global storage reads must be inside resolveStore, inside its try',
|
'both global storage reads must be inside resolveStore, inside its try',
|
||||||
);
|
);
|
||||||
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
|
assert.match(
|
||||||
|
resolver,
|
||||||
|
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
|
||||||
|
);
|
||||||
assert.match(code, /function readStored\(kind, injected, key\)/);
|
assert.match(code, /function readStored\(kind, injected, key\)/);
|
||||||
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
||||||
});
|
});
|
||||||
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
|
|||||||
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
||||||
|
|
||||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
|
||||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
'cockpit-mode',
|
||||||
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
|
'recording-mode',
|
||||||
);
|
'scene-playback-mode',
|
||||||
|
'ui-clean-view',
|
||||||
|
]);
|
||||||
// A surface that hides the card in CSS but is missing from the JS list would
|
// A surface that hides the card in CSS but is missing from the JS list would
|
||||||
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
||||||
const hideRule = css.slice(
|
const hideRule = css.slice(
|
||||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||||
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
|
css.indexOf(
|
||||||
|
'display: none',
|
||||||
|
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
|
const inCss = [
|
||||||
|
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
|
||||||
|
].map((m) => m[1]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
inCss.sort(),
|
inCss.sort(),
|
||||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||||
'every screen-claiming surface must appear in BOTH lists',
|
'every screen-claiming surface must appear in BOTH lists',
|
||||||
);
|
);
|
||||||
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
|
assert.match(
|
||||||
|
module,
|
||||||
|
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||||
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
|
const make = (classes) => ({
|
||||||
|
body: { classList: { contains: (name) => classes.includes(name) } },
|
||||||
|
});
|
||||||
assert.equal(exclusiveSurfaceActive(make([])), false);
|
assert.equal(exclusiveSurfaceActive(make([])), false);
|
||||||
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
||||||
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
|
assert.equal(
|
||||||
|
exclusiveSurfaceActive(make([name])),
|
||||||
|
true,
|
||||||
|
`${name} must count as exclusive`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
|
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
|
||||||
assert.equal(exclusiveSurfaceActive(undefined), false);
|
assert.equal(exclusiveSurfaceActive(undefined), false);
|
||||||
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the key handler refuses to act for a card that is not really on screen', () => {
|
test('the key handler refuses to act for a card that is not really on screen', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
||||||
// Real visibility, not just the class: the class survives while CSS hides the
|
// Real visibility, not just the class: the class survives while CSS hides the
|
||||||
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
assert.match(
|
||||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
module,
|
||||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
|
||||||
|
);
|
||||||
|
const keyboard = fs.readFileSync(
|
||||||
|
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const handler = keyboard.slice(
|
||||||
|
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||||
/!isActive\(\)/,
|
/!isActive\(\)/,
|
||||||
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an overlay with NO class to watch still disarms the launcher', () => {
|
test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
||||||
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
|||||||
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
||||||
// buried launcher and wrote the session flag while the lightbox stayed open.
|
// buried launcher and wrote the session flag while the lightbox stayed open.
|
||||||
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
||||||
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
|
assert.match(
|
||||||
|
overlay.slice(0, overlay.indexOf('}')),
|
||||||
|
/z-index: 200 !important/,
|
||||||
|
);
|
||||||
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
||||||
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
||||||
|
|
||||||
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
|||||||
|
|
||||||
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
|
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
|
||||||
// launcher acting under an overlay can never become why ESC stopped working.
|
// launcher acting under an overlay can never become why ESC stopped working.
|
||||||
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
|
assert.match(
|
||||||
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
|
covered,
|
||||||
|
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
covered,
|
||||||
|
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
|
||||||
|
);
|
||||||
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||||
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
const ui = fs.readFileSync(
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
new URL('./ui/radioBindings.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||||
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
|||||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||||
assert.match(claim, /event\.preventDefault\(\);/);
|
assert.match(claim, /event\.preventDefault\(\);/);
|
||||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||||
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
|
assert.doesNotMatch(
|
||||||
'the plain form leaves the launcher listening and is the defect itself');
|
claim,
|
||||||
|
/event\.stopPropagation\(\);/,
|
||||||
|
'the plain form leaves the launcher listening and is the defect itself',
|
||||||
|
);
|
||||||
|
|
||||||
// Belt on the launcher side: a key another surface already marked is not ours,
|
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||||
// whether or not that surface remembered to silence us.
|
// whether or not that surface remembered to silence us.
|
||||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
const keyboard = fs.readFileSync(
|
||||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const handler = keyboard.slice(
|
||||||
|
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||||
/event\.defaultPrevented\) return;/,
|
/event\.defaultPrevented\) return;/,
|
||||||
'a marked key must be somebody else\'s key',
|
"a marked key must be somebody else's key",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a refused write takes the tick back instead of promising "never again"', () => {
|
test('a refused write takes the tick back instead of promising "never again"', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
// The write stays best-effort; the OUTCOME is now reported, because a box left
|
// The write stays best-effort; the OUTCOME is now reported, because a box left
|
||||||
// ticked after a refused write tells the visitor the launcher is gone for good
|
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||||
// while it is already guaranteed to return next session.
|
// while it is already guaranteed to return next session.
|
||||||
const blocked = {
|
const blocked = {
|
||||||
getItem: () => null,
|
getItem: () => null,
|
||||||
setItem: () => { throw new Error('blocked'); },
|
setItem: () => {
|
||||||
removeItem: () => { throw new Error('blocked'); },
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
||||||
assert.equal(setFirstRunSuppressed(false, blocked), false);
|
assert.equal(setFirstRunSuppressed(false, blocked), false);
|
||||||
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
|
|||||||
module.indexOf('const onSuppressChange = (event) => {'),
|
module.indexOf('const onSuppressChange = (event) => {'),
|
||||||
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
||||||
);
|
);
|
||||||
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
assert.match(
|
||||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
handler,
|
||||||
|
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
handler,
|
||||||
|
/box\.checked = !wanted;/,
|
||||||
|
'a refused write must revert the tick',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const state = fs.readFileSync(
|
||||||
|
new URL('../docs/CURRENT-STATE.md', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
// A "reveal anyway after N seconds" would trade a benign no-show for the card
|
// A "reveal anyway after N seconds" would trade a benign no-show for the card
|
||||||
// punching through a recording in progress — recordings run long, and none of
|
// punching through a recording in progress — recordings run long, and none of
|
||||||
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
|
|||||||
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
||||||
module.indexOf(' const onViewportResize'),
|
module.indexOf(' const onViewportResize'),
|
||||||
);
|
);
|
||||||
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
|
assert.ok(
|
||||||
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
|
accepted.length > 0,
|
||||||
'the pin must cover the function the note governs, not just the note');
|
'the acceptance must be written where the next editor will read it',
|
||||||
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
|
);
|
||||||
'the acceptance is the decision NOT to time this out');
|
assert.match(
|
||||||
|
accepted,
|
||||||
|
/const syncToExclusiveSurfaces = \(\) => \{/,
|
||||||
|
'the pin must cover the function the note governs, not just the note',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
accepted,
|
||||||
|
/setTimeout|setInterval/,
|
||||||
|
'the acceptance is the decision NOT to time this out',
|
||||||
|
);
|
||||||
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
|
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
|
||||||
assert.match(state, /a surface class that never clears means no launcher for that page/i);
|
assert.match(
|
||||||
|
state,
|
||||||
|
/a surface class that never clears means no launcher for that page/i,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the scroll fade only appears when the list really overflows', () => {
|
test('the scroll fade only appears when the list really overflows', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
// A fade on a card where all five tiles fit promises a sixth mission that does
|
// A fade on a card where all five tiles fit promises a sixth mission that does
|
||||||
// not exist, which is worse than no affordance at all.
|
// not exist, which is worse than no affordance at all.
|
||||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
assert.match(
|
||||||
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
|
module,
|
||||||
|
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
module,
|
||||||
|
/choiceList\.dataset\.scrollable = String\(overflows\);/,
|
||||||
|
);
|
||||||
// ...and the CSS must be gated on that flag, never on the media query alone.
|
// ...and the CSS must be gated on that flag, never on the media query alone.
|
||||||
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
|
assert.match(
|
||||||
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
css,
|
||||||
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
|
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
|
||||||
|
);
|
||||||
|
const bareFade =
|
||||||
|
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||||
|
assert.doesNotMatch(
|
||||||
|
bareFade,
|
||||||
|
/mask-image/,
|
||||||
|
'the fade must never apply unconditionally',
|
||||||
|
);
|
||||||
// Rotating a phone changes which tiles fit, so it re-measures.
|
// Rotating a phone changes which tiles fit, so it re-measures.
|
||||||
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
||||||
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the launcher yields on engage and waits when a surface is already up', () => {
|
test('the launcher yields on engage and waits when a surface is already up', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
// Both directions from one observer: yield if it is up and something takes
|
// Both directions from one observer: yield if it is up and something takes
|
||||||
// the screen; wait to reveal if something already has it.
|
// the screen; wait to reveal if something already has it.
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
|
|||||||
// not see a new hold because of onboarding chrome.
|
// not see a new hold because of onboarding chrome.
|
||||||
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
||||||
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
||||||
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
|
assert.doesNotMatch(
|
||||||
|
module,
|
||||||
|
/setInterval|requestAnimationFrame\(function poll/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
|
function missionSpy({
|
||||||
|
contextOk = true,
|
||||||
|
layerResult = () => true,
|
||||||
|
globe = async () => ({ ok: true }),
|
||||||
|
} = {}) {
|
||||||
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
deps: {
|
deps: {
|
||||||
setContextMode: async (mode) => {
|
setContextMode: async (mode) => {
|
||||||
calls.contextModes.push(mode);
|
calls.contextModes.push(mode);
|
||||||
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
|
return contextOk
|
||||||
|
? { ok: true, mode }
|
||||||
|
: { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||||
},
|
},
|
||||||
setLayerEnabled: async (layerId) => {
|
setLayerEnabled: async (layerId) => {
|
||||||
calls.layerIds.push(layerId);
|
calls.layerIds.push(layerId);
|
||||||
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
|
|||||||
// went is the one-click globe-scale dump. Restoring the tile needs the
|
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||||
// globe-LOD declutter first.
|
// globe-LOD declutter first.
|
||||||
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
||||||
'contacts', 'space-missions', 'environmental', 'explore',
|
'contacts',
|
||||||
|
'space-missions',
|
||||||
|
'environmental',
|
||||||
|
'explore',
|
||||||
]);
|
]);
|
||||||
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
|
assert.equal(
|
||||||
'the infrastructure mission must be gone, not dormant');
|
FIRST_RUN_MISSIONS.infrastructure,
|
||||||
|
undefined,
|
||||||
|
'the infrastructure mission must be gone, not dormant',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
|
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
|
||||||
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
|
for (const [choice, mode] of [
|
||||||
|
['contacts', 'contacts'],
|
||||||
|
['space-missions', 'space-missions'],
|
||||||
|
]) {
|
||||||
const spy = missionSpy();
|
const spy = missionSpy();
|
||||||
const outcome = await runFirstRunChoice(choice, spy.deps);
|
const outcome = await runFirstRunChoice(choice, spy.deps);
|
||||||
assert.equal(outcome.ok, true);
|
assert.equal(outcome.ok, true);
|
||||||
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
|||||||
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
||||||
// post-launch, and the note must stay where the next editor will read it
|
// post-launch, and the note must stay where the next editor will read it
|
||||||
// rather than being re-discovered as a launcher bug.
|
// rather than being re-discovered as a launcher bug.
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const table = module.slice(
|
||||||
|
module.indexOf(' environmental: Object.freeze({'),
|
||||||
|
module.indexOf(' explore:'),
|
||||||
|
);
|
||||||
assert.match(table, /KEY REQUIRED/);
|
assert.match(table, /KEY REQUIRED/);
|
||||||
assert.match(table, /src\/loadingFeedback\.js/);
|
assert.match(table, /src\/loadingFeedback\.js/);
|
||||||
assert.match(table, /LEDGERED post-launch/);
|
assert.match(table, /LEDGERED post-launch/);
|
||||||
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
|
|||||||
assert.equal(outcome.ok, true);
|
assert.equal(outcome.ok, true);
|
||||||
assert.deepEqual(outcome.failedLayerIds, []);
|
assert.deepEqual(outcome.failedLayerIds, []);
|
||||||
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
||||||
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
||||||
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
|
|||||||
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
||||||
|
|
||||||
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
||||||
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
|
const flightDown = missionSpy({
|
||||||
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
|
globe: () => {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
||||||
const spy = missionSpy();
|
const spy = missionSpy();
|
||||||
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
||||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
assert.deepEqual(spy.calls, {
|
||||||
|
contextModes: [],
|
||||||
|
layerIds: [],
|
||||||
|
globeFlights: 0,
|
||||||
|
});
|
||||||
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
|
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
|
||||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
assert.deepEqual(spy.calls, {
|
||||||
|
contextModes: [],
|
||||||
|
layerIds: [],
|
||||||
|
globeFlights: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed Context mission reports the layers the facade named', async () => {
|
test('a failed Context mission reports the layers the facade named', async () => {
|
||||||
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
|
|||||||
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
|
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
|
||||||
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
||||||
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
||||||
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
|
assert.equal(
|
||||||
|
environmentalLabel().title,
|
||||||
|
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
||||||
|
|
||||||
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const code = module.slice(
|
||||||
|
module.indexOf('export function shouldShowFirstRun'),
|
||||||
|
);
|
||||||
|
|
||||||
// Layer enables ARE durable in this app and a mission tile IS that choice, so
|
// Layer enables ARE durable in this app and a mission tile IS that choice, so
|
||||||
// they run at the same origin a click on those rows uses.
|
// they run at the same origin a click on those rows uses.
|
||||||
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
|
|||||||
'_setModels3dParams',
|
'_setModels3dParams',
|
||||||
'setFeather',
|
'setFeather',
|
||||||
]) {
|
]) {
|
||||||
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
|
assert.doesNotMatch(
|
||||||
|
code,
|
||||||
|
new RegExp(forbidden),
|
||||||
|
`a mission must never touch ${forbidden}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The only durable panel write is the Context reveal, and only on the Context
|
// The only durable panel write is the Context reveal, and only on the Context
|
||||||
// missions — the globe missions open no panel at all.
|
// missions — the globe missions open no panel at all.
|
||||||
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
||||||
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
|
assert.equal(
|
||||||
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
|
panelWrites.length,
|
||||||
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
|
1,
|
||||||
|
'exactly one panel reveal, on the Context path',
|
||||||
|
);
|
||||||
|
const contextPath = code.slice(
|
||||||
|
code.indexOf('setContextMode: async (mode)'),
|
||||||
|
code.indexOf('setLayerEnabled:'),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
contextPath,
|
||||||
|
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the decision table is written down where the next editor will read it', () => {
|
test('the decision table is written down where the next editor will read it', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
module,
|
||||||
|
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
|
||||||
|
);
|
||||||
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
|
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
|
||||||
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
|
assert.ok(
|
||||||
|
module.includes(row),
|
||||||
|
`decision table is missing its "${row}" rows`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.match(module, /SHOW POLICY/);
|
assert.match(module, /SHOW POLICY/);
|
||||||
});
|
});
|
||||||
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
|
|||||||
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
||||||
|
|
||||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||||
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const html = expandApplicationHtml(
|
||||||
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
|
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||||
|
);
|
||||||
|
const startup = fs.readFileSync(
|
||||||
|
new URL('./app/startupChrome.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
|
assert.match(
|
||||||
|
html,
|
||||||
|
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
|
||||||
|
);
|
||||||
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
||||||
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
|
assert.match(
|
||||||
|
html,
|
||||||
|
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
|
||||||
|
);
|
||||||
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
|
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
|
||||||
assert.match(html, /<strong data-first-run-environmental-title>/);
|
assert.match(html, /<strong data-first-run-environmental-title>/);
|
||||||
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
||||||
// half of what it does is the defect this replaced. Only the VISIBLE <small>
|
// half of what it does is the defect this replaced. Only the VISIBLE <small>
|
||||||
// text counts; the comment beside it naturally says the words too.
|
// text counts; the comment beside it naturally says the words too.
|
||||||
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
|
const envTile = html.slice(
|
||||||
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
|
html.indexOf('data-first-run-choice="environmental"'),
|
||||||
|
);
|
||||||
|
const visible = envTile.slice(
|
||||||
|
envTile.indexOf('<small>'),
|
||||||
|
envTile.indexOf('</small>'),
|
||||||
|
);
|
||||||
assert.match(visible, /earthquakes/i);
|
assert.match(visible, /earthquakes/i);
|
||||||
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
|
assert.match(
|
||||||
|
visible,
|
||||||
|
/fires?/i,
|
||||||
|
'the tile must promise the fires it enables',
|
||||||
|
);
|
||||||
|
|
||||||
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
|
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
|
||||||
// unspaced em dash included. This is copy, not prose to be improved in a
|
// unspaced em dash included. This is copy, not prose to be improved in a
|
||||||
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
html.includes(
|
||||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
|
||||||
|
'—then you realize the sources are public and the data is real.</p>',
|
||||||
|
),
|
||||||
'the owner-authored first-run line must ship exactly as written',
|
'the owner-authored first-run line must ship exactly as written',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Menu order is the owner's, read straight off the markup.
|
// Menu order is the owner's, read straight off the markup.
|
||||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
|
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
|
||||||
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
|
(match) => match[1],
|
||||||
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
|
);
|
||||||
'the removed tile must leave no markup behind');
|
assert.deepEqual(order, [
|
||||||
|
'contacts',
|
||||||
|
'space-missions',
|
||||||
|
'environmental',
|
||||||
|
'explore',
|
||||||
|
]);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
html,
|
||||||
|
/data-first-run-choice="infrastructure"/,
|
||||||
|
'the removed tile must leave no markup behind',
|
||||||
|
);
|
||||||
|
|
||||||
assert.match(startup, /styleManager\.initialRestorePromise/);
|
assert.match(startup, /styleManager\.initialRestorePromise/);
|
||||||
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
|
assert.ok(
|
||||||
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
|
startup.indexOf("loadingScreen.classList.add('hidden')") <
|
||||||
|
startup.indexOf(
|
||||||
|
"loadingScreen.addEventListener('transitionend', revealFirstRun",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
startup,
|
||||||
|
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
|
||||||
|
);
|
||||||
|
|
||||||
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
|
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
|
||||||
assert.match(css, /body\.recording-mode #first-run-launcher/);
|
assert.match(css, /body\.recording-mode #first-run-launcher/);
|
||||||
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
|
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
|
||||||
// matching one of THOSE would let the launcher's own opt-out be deleted.
|
// matching one of THOSE would let the launcher's own opt-out be deleted.
|
||||||
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
|
const reducedMotion = [
|
||||||
.map((match) => match[1]);
|
...css.matchAll(
|
||||||
|
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
|
||||||
|
),
|
||||||
|
].map((match) => match[1]);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
|
reducedMotion.some(
|
||||||
|
(block) =>
|
||||||
|
block.includes('#first-run-launcher') &&
|
||||||
|
block.includes('.first-run-choices'),
|
||||||
|
),
|
||||||
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
|
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
|
||||||
);
|
);
|
||||||
// The card must be click-through until revealed and again while it fades out.
|
// The card must be click-through until revealed and again while it fades out.
|
||||||
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
|
const base = css.slice(
|
||||||
|
css.indexOf('#first-run-launcher {'),
|
||||||
|
css.indexOf('#first-run-launcher.visible'),
|
||||||
|
);
|
||||||
assert.match(base, /pointer-events: none/);
|
assert.match(base, /pointer-events: none/);
|
||||||
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
|
assert.match(
|
||||||
|
css,
|
||||||
|
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
|
||||||
|
);
|
||||||
// The card is a flex column so its mission list can scroll on a short
|
// The card is a flex column so its mission list can scroll on a short
|
||||||
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
||||||
// `[hidden] { display: none }`, which would strand the card in the
|
// `[hidden] { display: none }`, which would strand the card in the
|
||||||
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(
|
||||||
|
new URL('./firstRunExperience.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
// aria-disabled, never the `disabled` property: disabling a focused button
|
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
||||||
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
|
assert.match(
|
||||||
|
module,
|
||||||
|
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(module, /button\.disabled = /);
|
assert.doesNotMatch(module, /button\.disabled = /);
|
||||||
// Tab is confined to the launcher, and ESC always releases it.
|
// Tab is confined to the launcher, and ESC always releases it.
|
||||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
const keyboard = fs.readFileSync(
|
||||||
|
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
assert.match(module, /keyboard\.activate\(\)/);
|
assert.match(module, /keyboard\.activate\(\)/);
|
||||||
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
||||||
assert.match(keyboard, /event\.key !== 'Tab'/);
|
assert.match(keyboard, /event\.key !== 'Tab'/);
|
||||||
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||||
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
|
const ui = fs.readFileSync(
|
||||||
|
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||||
// now, so it was opening to offer controls for things already happening —
|
// now, so it was opening to offer controls for things already happening —
|
||||||
// while competing with the mission card for the one first impression there is.
|
// while competing with the mission card for the one first impression there is.
|
||||||
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
|||||||
);
|
);
|
||||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||||
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
|
const block = ui.slice(
|
||||||
|
ui.indexOf(
|
||||||
|
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
|
||||||
|
),
|
||||||
|
);
|
||||||
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
||||||
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
||||||
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
||||||
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
// Canonical serialization pins every tool name, description, property and
|
// Canonical serialization pins every tool name, description, property and
|
||||||
// ordering while allowing source formatting. Derived from the unchanged
|
// ordering while allowing source formatting. Derived from the unchanged
|
||||||
// release schema before formatting (the previous source-byte pin passed).
|
// release schema before formatting (the previous source-byte pin passed).
|
||||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
(tool) => tool.name !== 'set_cyber_sonar',
|
||||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
);
|
||||||
|
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||||
|
.parameters.properties.layout;
|
||||||
|
assert.deepEqual(hudLayout.enum, [
|
||||||
|
'tactical',
|
||||||
|
'operator',
|
||||||
|
'minimal',
|
||||||
|
'cyber',
|
||||||
|
]);
|
||||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||||
const block = JSON.stringify(legacyTools);
|
const block = JSON.stringify(legacyTools);
|
||||||
@@ -681,7 +1001,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
const instructions = fs.readFileSync(
|
||||||
|
new URL('../server/providers/openai/instructions.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
// ...and the mapping that makes them reachable by voice is one instruction
|
// ...and the mapping that makes them reachable by voice is one instruction
|
||||||
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
||||||
@@ -692,23 +1015,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
|||||||
/\n\s+'NAMED VIEWS are shorthand/,
|
/\n\s+'NAMED VIEWS are shorthand/,
|
||||||
'the mission mapping must be an active instruction entry, not commented out',
|
'the mission mapping must be an active instruction entry, not commented out',
|
||||||
);
|
);
|
||||||
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
|
const mapping = instructions.slice(
|
||||||
|
instructions.indexOf('NAMED VIEWS are shorthand'),
|
||||||
|
);
|
||||||
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
||||||
for (const layerId of [
|
for (const layerId of [
|
||||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
|
'local-datacenters',
|
||||||
|
'local-dams',
|
||||||
|
'telegeography-submarine-cables',
|
||||||
|
'local-firms',
|
||||||
|
'earthquakes',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
|
assert.ok(
|
||||||
|
paragraph.includes(layerId),
|
||||||
|
`mapping must name the existing ${layerId} enum value`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.ok(paragraph.includes('zoom_to_globe'));
|
assert.ok(paragraph.includes('zoom_to_globe'));
|
||||||
assert.ok(paragraph.includes('set_layer_visibility'));
|
assert.ok(paragraph.includes('set_layer_visibility'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
||||||
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
|
const tool = GEV_REALTIME_TOOLS.find(
|
||||||
|
(tool) => tool.name === 'set_layer_visibility',
|
||||||
|
);
|
||||||
const allowedLayers = tool.parameters.properties.layerId.enum;
|
const allowedLayers = tool.parameters.properties.layerId.enum;
|
||||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
|
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
|
||||||
|
(mission) => mission.layerIds || [],
|
||||||
|
);
|
||||||
assert.ok(missionLayerIds.length > 0);
|
assert.ok(missionLayerIds.length > 0);
|
||||||
for (const layerId of missionLayerIds) {
|
for (const layerId of missionLayerIds) {
|
||||||
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
|
assert.ok(
|
||||||
|
allowedLayers.includes(layerId),
|
||||||
|
`${layerId} must already be an allowed enum value`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+125
-19
@@ -16,8 +16,9 @@ import { applicationServices } from './services/application.js';
|
|||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { forward as toMGRS } from 'mgrs';
|
import { forward as toMGRS } from 'mgrs';
|
||||||
import { CITY_POIS } from './locations.js';
|
import { CITY_POIS } from './cityPois.js';
|
||||||
import { composeLocalityTag } from './hudLocality.js';
|
import { composeLocalityTag } from './hudLocality.js';
|
||||||
|
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||||
import {
|
import {
|
||||||
ellipsoidalToMslDisplayM,
|
ellipsoidalToMslDisplayM,
|
||||||
ensureGeoidReady,
|
ensureGeoidReady,
|
||||||
@@ -27,7 +28,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
|
|||||||
import {
|
import {
|
||||||
hudSummaryMatchesProvenance,
|
hudSummaryMatchesProvenance,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenanceTag,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,8 @@ import {
|
|||||||
DEFAULT_HUD_LAYOUT,
|
DEFAULT_HUD_LAYOUT,
|
||||||
normalizeHudLayout,
|
normalizeHudLayout,
|
||||||
} from './hudLayouts.js';
|
} 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. */
|
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||||
const HUD_COLORS = {
|
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. */
|
/** Shader modes that automatically show the HUD overlay. */
|
||||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||||
|
|
||||||
@@ -185,6 +261,7 @@ export class IntelHUD {
|
|||||||
this._el = document.getElementById('intel-hud');
|
this._el = document.getElementById('intel-hud');
|
||||||
if (!this._el) return;
|
if (!this._el) return;
|
||||||
|
|
||||||
|
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
|
||||||
this._el.innerHTML = `
|
this._el.innerHTML = `
|
||||||
<div class="hud-sonar" aria-hidden="true"></div>
|
<div class="hud-sonar" aria-hidden="true"></div>
|
||||||
|
|
||||||
@@ -199,17 +276,17 @@ export class IntelHUD {
|
|||||||
<div class="hud-content">
|
<div class="hud-content">
|
||||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||||
<div class="hud-mode" id="hud-mode">NORMAL</div>
|
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
||||||
<div class="hud-summary-wrap">
|
<div class="hud-summary-wrap">
|
||||||
<div class="hud-summary-label">SUMMARY</div>
|
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||||
<div class="hud-summary" id="hud-summary">Awaiting telemetry...</div>
|
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-corner hud-top-right">
|
<div class="hud-corner hud-top-right">
|
||||||
<div class="hud-content" style="text-align: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 class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-bracket">┐</div>
|
<div class="hud-bracket">┐</div>
|
||||||
@@ -227,7 +304,7 @@ export class IntelHUD {
|
|||||||
<div class="hud-content" style="text-align:right">
|
<div class="hud-content" style="text-align:right">
|
||||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||||
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
|
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-bracket">┘</div>
|
<div class="hud-bracket">┘</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,9 +432,11 @@ export class IntelHUD {
|
|||||||
const formatted = this._formatMGRS(mgrsStr);
|
const formatted = this._formatMGRS(mgrsStr);
|
||||||
mgrsLabel = formatted;
|
mgrsLabel = formatted;
|
||||||
const el = document.getElementById('hud-mgrs');
|
const el = document.getElementById('hud-mgrs');
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
if (el) el.textContent = `MGRS: ${formatted}`;
|
if (el) el.textContent = `MGRS: ${formatted}`;
|
||||||
} catch {
|
} catch {
|
||||||
const el = document.getElementById('hud-mgrs');
|
const el = document.getElementById('hud-mgrs');
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
if (el) el.textContent = 'MGRS: ---';
|
if (el) el.textContent = 'MGRS: ---';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +445,7 @@ export class IntelHUD {
|
|||||||
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
||||||
const bottomEl = document.getElementById('hud-bottom-line');
|
const bottomEl = document.getElementById('hud-bottom-line');
|
||||||
if (bottomEl) {
|
if (bottomEl) {
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +461,7 @@ export class IntelHUD {
|
|||||||
);
|
);
|
||||||
const gsdEl = document.getElementById('hud-gsd');
|
const gsdEl = document.getElementById('hud-gsd');
|
||||||
if (gsdEl)
|
if (gsdEl)
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||||
|
|
||||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||||
@@ -393,6 +474,7 @@ export class IntelHUD {
|
|||||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||||
if (altEl)
|
if (altEl)
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||||
|
|
||||||
// Collection timestamp
|
// Collection timestamp
|
||||||
@@ -402,6 +484,7 @@ export class IntelHUD {
|
|||||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +493,7 @@ export class IntelHUD {
|
|||||||
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
||||||
const ona = Math.max(0, 90 + pitchDeg);
|
const ona = Math.max(0, 90 + pitchDeg);
|
||||||
const onaEl = document.getElementById('hud-ona');
|
const onaEl = document.getElementById('hud-ona');
|
||||||
|
// i18n-ignore: sensor codes
|
||||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||||
|
|
||||||
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
||||||
@@ -634,10 +718,10 @@ export class IntelHUD {
|
|||||||
*/
|
*/
|
||||||
_composeSummary() {
|
_composeSummary() {
|
||||||
const m = this._latestMetrics;
|
const m = this._latestMetrics;
|
||||||
if (!m) return 'Awaiting telemetry...';
|
if (!m) return t('Awaiting telemetry...');
|
||||||
|
|
||||||
const modeEl = document.getElementById('hud-mode');
|
const modeEl = document.getElementById('hud-mode');
|
||||||
const modeLabel = modeEl?.textContent || 'NORMAL';
|
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
|
||||||
const region = this._regionLabel(m.latDeg, m.lonDeg);
|
const region = this._regionLabel(m.latDeg, m.lonDeg);
|
||||||
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
||||||
const band = this._viewBand(m.altM);
|
const band = this._viewBand(m.altM);
|
||||||
@@ -649,20 +733,41 @@ export class IntelHUD {
|
|||||||
// together, so they must never disagree. The view band above deliberately
|
// together, so they must never disagree. The view band above deliberately
|
||||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||||
|
// Display only: the AI summary request carries its own context, so this
|
||||||
|
// line is built in the active language.
|
||||||
const altTag =
|
const altTag =
|
||||||
altDisplayM >= 1000
|
altDisplayM >= 1000
|
||||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
? hudQuantity(altDisplayM / 1000, 'km', 1)
|
||||||
: `${Math.round(altDisplayM)}M`;
|
: hudQuantity(Math.round(altDisplayM), 'm');
|
||||||
const winTag = window
|
const winTag = window
|
||||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
|
||||||
: 'N/A';
|
: t('N/A');
|
||||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
const localityTag = composeLocalityTag(
|
||||||
|
nearest,
|
||||||
|
m.latDeg,
|
||||||
|
m.lonDeg,
|
||||||
|
LOCALIZED_LOCALITY,
|
||||||
|
);
|
||||||
|
|
||||||
const provenance = hudTelemetryProvenanceTag(
|
const provenance = localizedProvenanceTag(
|
||||||
this._dataManager?.getAll?.() || [],
|
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||||
|
);
|
||||||
|
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||||
|
const line = t(
|
||||||
|
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
|
||||||
|
{
|
||||||
|
mode: modeLabel,
|
||||||
|
band: viewBandLabel(band),
|
||||||
|
locality: localityTag,
|
||||||
|
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
|
||||||
|
alt: altTag,
|
||||||
|
window: winTag,
|
||||||
|
sun: formatNumber(Math.round(m.sunEl)),
|
||||||
|
ona: formatNumber(Math.round(m.ona)),
|
||||||
|
zone: localTag,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
|
||||||
return provenance ? `${line} | ${provenance}` : line;
|
return provenance ? `${line} | ${provenance}` : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,8 +923,9 @@ export class IntelHUD {
|
|||||||
// Update mode label
|
// Update mode label
|
||||||
const modeEl = document.getElementById('hud-mode');
|
const modeEl = document.getElementById('hud-mode');
|
||||||
if (modeEl) {
|
if (modeEl) {
|
||||||
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
|
modeEl.textContent = MODE_LABELS[styleName]
|
||||||
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
|
? t(MODE_LABELS[styleName])
|
||||||
|
: styleName.toUpperCase();
|
||||||
}
|
}
|
||||||
// Update color scheme
|
// Update color scheme
|
||||||
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ test('HUD sliders and location search have descriptive explicit names', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the first-run checkbox keeps its native visible label', () => {
|
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', () => {
|
test('generated style sliders use the visible parameter label as their name', () => {
|
||||||
assert.match(parameters, /label\.textContent\s*=\s*metadata\.label;/);
|
assert.match(parameters, /label\.textContent\s*=\s*t\(metadata\.label\);/);
|
||||||
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*metadata\.label\)/);
|
assert.match(parameters, /slider\.setAttribute\(['"]aria-label['"],\s*t\(metadata\.label\)\)/);
|
||||||
});
|
});
|
||||||
+23
-3
@@ -23,6 +23,14 @@
|
|||||||
*/
|
*/
|
||||||
export const NEAR_POI_MAX_KM = 150;
|
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`.
|
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
|
||||||
* @param {number} value Signed decimal degrees.
|
* @param {number} value Signed decimal degrees.
|
||||||
@@ -42,13 +50,25 @@ function coordinateTag(value, positive, negative) {
|
|||||||
* catalogue is empty.
|
* catalogue is empty.
|
||||||
* @param {number} latDeg Camera latitude in decimal degrees.
|
* @param {number} latDeg Camera latitude in decimal degrees.
|
||||||
* @param {number} lonDeg Camera longitude in decimal degrees.
|
* @param {number} lonDeg Camera longitude in decimal degrees.
|
||||||
|
* @param {{near: Function, sector: Function}} [format] Builds the line from
|
||||||
|
* `{ poi, city, distKm }` or `{ latDeg, lonDeg }`; names arrive as
|
||||||
|
* catalogued (`Tokyo`), not upper-cased.
|
||||||
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
||||||
* otherwise `SECTOR <lat> <lon>`.
|
* otherwise `SECTOR <lat> <lon>`.
|
||||||
*/
|
*/
|
||||||
export function composeLocalityTag(nearest, latDeg, lonDeg) {
|
export function composeLocalityTag(
|
||||||
|
nearest,
|
||||||
|
latDeg,
|
||||||
|
lonDeg,
|
||||||
|
format = ENGLISH_LOCALITY,
|
||||||
|
) {
|
||||||
const distKm = Number(nearest?.distKm);
|
const distKm = Number(nearest?.distKm);
|
||||||
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
||||||
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
|
return format.near({
|
||||||
|
poi: String(nearest.poi),
|
||||||
|
city: String(nearest.city),
|
||||||
|
distKm,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
|
return format.sector({ latDeg, lonDeg });
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { composeLocalityTag, NEAR_POI_MAX_KM } from './hudLocality.js';
|
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 ALCATRAZ = { poi: 'Alcatraz Island', city: 'San Francisco' };
|
||||||
const LINCOLN = { poi: 'Lincoln Memorial', city: 'Washington DC' };
|
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', () => {
|
test('a landmark under the camera reads NEAR', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
|
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
|
||||||
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
|
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -40,20 +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', () => {
|
test('the boundary is pinned on both sides, inclusive at the bound', () => {
|
||||||
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
|
const at = composeLocalityTag(
|
||||||
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
|
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
|
||||||
|
40,
|
||||||
|
-78,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
at,
|
||||||
|
/^NEAR LINCOLN MEMORIAL/,
|
||||||
|
'exactly at the bound still reads NEAR',
|
||||||
|
);
|
||||||
|
|
||||||
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
|
const just_under = composeLocalityTag(
|
||||||
|
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
|
||||||
|
40,
|
||||||
|
-78,
|
||||||
|
);
|
||||||
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
|
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
|
||||||
|
|
||||||
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
|
const just_over = composeLocalityTag(
|
||||||
|
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
|
||||||
|
40,
|
||||||
|
-78,
|
||||||
|
);
|
||||||
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
|
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('southern and western hemispheres carry the right suffixes', () => {
|
test('southern and western hemispheres carry the right suffixes', () => {
|
||||||
// Rio and Honolulu — the two the fallback already handled correctly in the field.
|
// Rio and Honolulu — the two the fallback already handled correctly in the field.
|
||||||
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
|
assert.equal(
|
||||||
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
|
composeLocalityTag(null, -22.9068, -43.1729),
|
||||||
|
'SECTOR 22.91S 43.17W',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
composeLocalityTag(null, 21.3069, -157.8583),
|
||||||
|
'SECTOR 21.31N 157.86W',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The tests above all pass against a hud.js that still computes the tag inline —
|
// The tests above all pass against a hud.js that still computes the tag inline —
|
||||||
@@ -72,7 +96,9 @@ test('hud.js actually composes its summary through this helper', () => {
|
|||||||
'hud.js must import composeLocalityTag from ./hudLocality.js',
|
'hud.js must import composeLocalityTag from ./hudLocality.js',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
|
has(
|
||||||
|
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'_composeSummary must build its locality tag through composeLocalityTag()',
|
'_composeSummary must build its locality tag through composeLocalityTag()',
|
||||||
);
|
);
|
||||||
@@ -91,6 +117,41 @@ test('hud.js actually composes its summary through this helper', () => {
|
|||||||
test('a missing or malformed nearest POI never crashes the summary', () => {
|
test('a missing or malformed nearest POI never crashes the summary', () => {
|
||||||
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
||||||
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
|
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
|
||||||
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
|
assert.match(
|
||||||
|
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
|
||||||
|
/^SECTOR /,
|
||||||
|
);
|
||||||
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a formatter builds the line from the same parts', () => {
|
||||||
|
const format = {
|
||||||
|
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
||||||
|
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
|
||||||
|
'near:Alcatraz Island|San Francisco|2.4',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
composeLocalityTag(null, 21.3, -157.8, format),
|
||||||
|
'sector:21.3|-157.8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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')),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic telemetry suffix when an enabled feed is not nominal.
|
* The feed state and up to two layer names for the HUD telemetry suffix when
|
||||||
|
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
||||||
|
* id, `names` are `layer.name`); the HUD translates them for display.
|
||||||
* @param {Array<object>} [layers]
|
* @param {Array<object>} [layers]
|
||||||
* @param {{ now?: number }} [options]
|
* @param {{ now?: number }} [options]
|
||||||
* @returns {string|null}
|
* @returns {{ state: string, names: string[] }|null}
|
||||||
*/
|
*/
|
||||||
export function hudTelemetryProvenanceTag(layers = [], options) {
|
export function hudTelemetryProvenance(layers = [], options) {
|
||||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||||
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
|
|||||||
.filter(
|
.filter(
|
||||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||||
)
|
)
|
||||||
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
|
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
return { state: envelope.overall, names };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||||
|
|||||||
+101
-29
@@ -5,7 +5,7 @@ import {
|
|||||||
HUD_SUMMARY_INSTRUCTIONS,
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
hudSummaryLayerContext,
|
hudSummaryLayerContext,
|
||||||
hudTelemetryProvenanceTag,
|
hudTelemetryProvenance,
|
||||||
isHudSummaryUnconfigured,
|
isHudSummaryUnconfigured,
|
||||||
keylessHudSummaryResponse,
|
keylessHudSummaryResponse,
|
||||||
} from './hudSummaryResponse.js';
|
} from './hudSummaryResponse.js';
|
||||||
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
|
function invokeRoute(
|
||||||
|
handler,
|
||||||
|
{ method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {},
|
||||||
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const headers = new Map();
|
const headers = new Map();
|
||||||
const req = {
|
const req = {
|
||||||
@@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => {
|
|||||||
test('recognizes only the exact deliberate no-key fallback response', () => {
|
test('recognizes only the exact deliberate no-key fallback response', () => {
|
||||||
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
||||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, {
|
||||||
...UNCONFIGURED_PAYLOAD,
|
...UNCONFIGURED_PAYLOAD,
|
||||||
error: 'provider failed',
|
error: 'provider failed',
|
||||||
}), false);
|
}),
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, {
|
||||||
...UNCONFIGURED_PAYLOAD,
|
...UNCONFIGURED_PAYLOAD,
|
||||||
summary: 'Unexpected provider output',
|
summary: 'Unexpected provider output',
|
||||||
}), false);
|
}),
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, {
|
||||||
...UNCONFIGURED_PAYLOAD,
|
...UNCONFIGURED_PAYLOAD,
|
||||||
configured: true,
|
configured: true,
|
||||||
}), false);
|
}),
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, {
|
||||||
...UNCONFIGURED_PAYLOAD,
|
...UNCONFIGURED_PAYLOAD,
|
||||||
unexpected: true,
|
unexpected: true,
|
||||||
}), false);
|
}),
|
||||||
assert.equal(isHudSummaryUnconfigured(200, {
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, {
|
||||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
}), false);
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not hide real provider and HTTP failures', () => {
|
test('does not hide real provider and HTTP failures', () => {
|
||||||
assert.equal(isHudSummaryUnconfigured(502, {
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(502, {
|
||||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||||
}), false);
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||||
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
|
assert.equal(
|
||||||
|
isHudSummaryUnconfigured(200, { error: 'provider failed' }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
|
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
|
||||||
@@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
|||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
const response = await invokeRoute(hud, { method: 'POST' });
|
const response = await invokeRoute(hud, { method: 'POST' });
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
assert.equal(
|
||||||
|
response.headers['content-type'],
|
||||||
|
'application/json; charset=utf-8',
|
||||||
|
);
|
||||||
assert.equal(response.headers['cache-control'], 'no-store');
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||||
else process.env.OPENAI_API_KEY = previousKey;
|
else process.env.OPENAI_API_KEY = previousKey;
|
||||||
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
if (previousLimit === undefined)
|
||||||
|
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
|||||||
id: 'flights',
|
id: 'flights',
|
||||||
name: 'Live Flights',
|
name: 'Live Flights',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
|
stats: {
|
||||||
|
stale: true,
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: now - 240_000,
|
||||||
|
source: 'OpenSky Network',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'earthquakes',
|
id: 'earthquakes',
|
||||||
@@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
|||||||
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||||
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||||
assert.equal(context.feedProvenance.overall, 'stale');
|
assert.equal(context.feedProvenance.overall, 'stale');
|
||||||
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||||
assert.equal(hudTelemetryProvenanceTag([{
|
state: 'stale',
|
||||||
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
names: ['Live Flights'],
|
||||||
}], { now }), null);
|
});
|
||||||
|
assert.equal(
|
||||||
|
hudTelemetryProvenance(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Live Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: { count: 4, lastUpdate: now },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ now },
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
|
assert.match(
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
/five words MUST include that feedState token/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
HUD_SUMMARY_INSTRUCTIONS,
|
||||||
|
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
||||||
|
);
|
||||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||||
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
|
const local = readFileSync(
|
||||||
|
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||||
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
|
const { hudSummaryMatchesProvenance } =
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
|
await import('./hudSummaryResponse.js');
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
|
assert.equal(
|
||||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
|
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||||
|
overall: 'stale',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
||||||
|
overall: 'stale',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||||
|
overall: 'nominal',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -20,12 +20,22 @@ import {
|
|||||||
* the chip and the dialog are removed outright.
|
* 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) {
|
export function keySetupChipLabel(status) {
|
||||||
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
|
||||||
return missing > 0
|
return missing > 0
|
||||||
? `POWER UP · ${missing} ${missing === 1 ? 'KEY' : 'KEYS'} WAITING`
|
? tn(
|
||||||
: 'POWERED UP';
|
missing,
|
||||||
|
'POWER UP · {count} KEY WAITING',
|
||||||
|
'POWER UP · {count} KEYS WAITING',
|
||||||
|
)
|
||||||
|
: t('POWERED UP');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +52,73 @@ export function collectKeyUpdates(fields) {
|
|||||||
return updates;
|
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) {
|
function sleepWithSignal(ms, signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal?.aborted) {
|
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({
|
export async function waitForChatGptOAuth({
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
signal,
|
signal,
|
||||||
@@ -78,8 +183,9 @@ export async function waitForChatGptOAuth({
|
|||||||
if (signal?.aborted) return false;
|
if (signal?.aborted) return false;
|
||||||
if (response.ok && payload.available) return true;
|
if (response.ok && payload.available) return true;
|
||||||
if (!response.ok || payload.loginFailed) {
|
if (!response.ok || payload.loginFailed) {
|
||||||
throw new Error(
|
throw oauthCheckError(
|
||||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
payload.error,
|
||||||
|
t('Could not check ChatGPT sign-in. Try again.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (now() >= deadline) break;
|
if (now() >= deadline) break;
|
||||||
@@ -116,12 +222,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
|
|||||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||||
state.textContent =
|
state.textContent =
|
||||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
mode === 'oauth'
|
||||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
? t('VOICE AUTH · CHATGPT OAUTH')
|
||||||
|
: t('VOICE AUTH · API KEY');
|
||||||
|
toggle.textContent =
|
||||||
|
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
||||||
toggle.title =
|
toggle.title =
|
||||||
mode === 'oauth'
|
mode === 'oauth'
|
||||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
||||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
: t(
|
||||||
|
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
||||||
|
);
|
||||||
// Keep the control focusable while the delegated handler blocks repeats.
|
// Keep the control focusable while the delegated handler blocks repeats.
|
||||||
toggle.setAttribute('aria-disabled', String(busy));
|
toggle.setAttribute('aria-disabled', String(busy));
|
||||||
}
|
}
|
||||||
@@ -141,21 +252,22 @@ function buildRow(documentRef, key) {
|
|||||||
led.className = 'key-setup-led';
|
led.className = 'key-setup-led';
|
||||||
led.setAttribute('aria-hidden', 'true');
|
led.setAttribute('aria-hidden', 'true');
|
||||||
const title = documentRef.createElement('strong');
|
const title = documentRef.createElement('strong');
|
||||||
title.textContent = key.title;
|
title.textContent = key.title; // i18n-ignore: provider product name
|
||||||
const tier = documentRef.createElement('span');
|
const tier = documentRef.createElement('span');
|
||||||
tier.className = 'key-setup-tier';
|
tier.className = 'key-setup-tier';
|
||||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||||
tier.title =
|
tier.title =
|
||||||
key.tier === 'metered'
|
key.tier === 'metered'
|
||||||
? 'Metered — a billing-enabled account'
|
? t('Metered — a billing-enabled account')
|
||||||
: 'Free key — register, paste, done';
|
: t('Free key — register, paste, done');
|
||||||
head.append(led, title, tier);
|
head.append(led, title, tier);
|
||||||
if (key.clientExposed) {
|
if (key.clientExposed) {
|
||||||
const exposed = documentRef.createElement('span');
|
const exposed = documentRef.createElement('span');
|
||||||
exposed.className = 'key-setup-exposed';
|
exposed.className = 'key-setup-exposed';
|
||||||
exposed.textContent = 'browser-side';
|
exposed.textContent = t('browser-side');
|
||||||
exposed.title =
|
exposed.title = t(
|
||||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
||||||
|
);
|
||||||
head.append(exposed);
|
head.append(exposed);
|
||||||
}
|
}
|
||||||
if (external) {
|
if (external) {
|
||||||
@@ -163,9 +275,10 @@ function buildRow(documentRef, key) {
|
|||||||
// facts this panel reports, never values it rewrites or deletes.
|
// facts this panel reports, never values it rewrites or deletes.
|
||||||
const badge = documentRef.createElement('span');
|
const badge = documentRef.createElement('span');
|
||||||
badge.className = 'key-setup-external';
|
badge.className = 'key-setup-external';
|
||||||
badge.textContent = 'configured externally';
|
badge.textContent = t('configured externally');
|
||||||
badge.title =
|
badge.title = t(
|
||||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
||||||
|
);
|
||||||
head.append(badge);
|
head.append(badge);
|
||||||
}
|
}
|
||||||
const get = documentRef.createElement('a');
|
const get = documentRef.createElement('a');
|
||||||
@@ -173,12 +286,12 @@ function buildRow(documentRef, key) {
|
|||||||
get.href = key.getUrl;
|
get.href = key.getUrl;
|
||||||
get.target = '_blank';
|
get.target = '_blank';
|
||||||
get.rel = 'noopener noreferrer';
|
get.rel = 'noopener noreferrer';
|
||||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
||||||
head.append(get);
|
head.append(get);
|
||||||
|
|
||||||
const unlocks = documentRef.createElement('p');
|
const unlocks = documentRef.createElement('p');
|
||||||
unlocks.className = 'key-setup-unlocks';
|
unlocks.className = 'key-setup-unlocks';
|
||||||
unlocks.textContent = key.unlocks;
|
unlocks.textContent = t(key.unlocks);
|
||||||
|
|
||||||
row.append(head, unlocks);
|
row.append(head, unlocks);
|
||||||
if (!external) {
|
if (!external) {
|
||||||
@@ -194,8 +307,8 @@ function buildRow(documentRef, key) {
|
|||||||
input.dataset.envVar = envVar;
|
input.dataset.envVar = envVar;
|
||||||
input.setAttribute('aria-label', envVar);
|
input.setAttribute('aria-label', envVar);
|
||||||
input.placeholder = key.set
|
input.placeholder = key.set
|
||||||
? `${envVar} saved — paste to replace`
|
? t('{envVar} saved — paste to replace', { envVar })
|
||||||
: `paste ${envVar}`;
|
: t('paste {envVar}', { envVar });
|
||||||
fields.append(input);
|
fields.append(input);
|
||||||
}
|
}
|
||||||
if (key.managed === 'file') {
|
if (key.managed === 'file') {
|
||||||
@@ -203,8 +316,10 @@ function buildRow(documentRef, key) {
|
|||||||
remove.type = 'button';
|
remove.type = 'button';
|
||||||
remove.className = 'key-setup-remove';
|
remove.className = 'key-setup-remove';
|
||||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
||||||
remove.textContent = 'REMOVE';
|
remove.textContent = t('REMOVE');
|
||||||
remove.title = `Remove ${key.title} from this app's saved keys`;
|
remove.title = t("Remove {title} from this app's saved keys", {
|
||||||
|
title: key.title,
|
||||||
|
});
|
||||||
fields.append(remove);
|
fields.append(remove);
|
||||||
}
|
}
|
||||||
row.append(fields);
|
row.append(fields);
|
||||||
@@ -225,17 +340,22 @@ function buildRow(documentRef, key) {
|
|||||||
return row;
|
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 theme = documentRef?.documentElement;
|
||||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
||||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
const Observer = documentRef?.defaultView?.MutationObserver;
|
||||||
if (!theme || !toolbar || !Observer) return () => {};
|
const home = chip?.parentNode;
|
||||||
|
if (!theme || !toolbar || !Observer || !home) return () => {};
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (theme.dataset.uiTheme === 'cyber') {
|
if (theme.dataset.uiTheme === 'cyber') {
|
||||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
||||||
} else if (chip.parentNode !== root.parentNode) {
|
} else if (chip.parentNode !== home) {
|
||||||
root.before(chip);
|
home.prepend(chip);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const observer = new Observer(sync);
|
const observer = new Observer(sync);
|
||||||
@@ -261,9 +381,19 @@ export async function initKeySetup({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let disposeControls = () => {};
|
let disposeControls = () => {};
|
||||||
let disposePlacement = () => {};
|
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 = () => {
|
const destroy = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
unregisterUnsavedWork();
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
signal?.removeEventListener('abort', destroy);
|
signal?.removeEventListener('abort', destroy);
|
||||||
disposeControls();
|
disposeControls();
|
||||||
@@ -295,7 +425,7 @@ export async function initKeySetup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
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 applyButton = root.querySelector('[data-key-setup-apply]');
|
||||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||||
@@ -314,7 +444,9 @@ export async function initKeySetup({
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
chipLabel.textContent = keySetupChipLabel(status);
|
chipLabel.textContent = keySetupChipLabel(status);
|
||||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
chip.title = t('Project keys: {status}', {
|
||||||
|
status: keySetupChipLabel(status),
|
||||||
|
});
|
||||||
chip.setAttribute('aria-label', chip.title);
|
chip.setAttribute('aria-label', chip.title);
|
||||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||||
@@ -369,17 +501,19 @@ export async function initKeySetup({
|
|||||||
|
|
||||||
const storeLabel = () =>
|
const storeLabel = () =>
|
||||||
status?.store === 'pinokio-environment'
|
status?.store === 'pinokio-environment'
|
||||||
? 'your app configuration'
|
? t('your app configuration')
|
||||||
: 'your local .env';
|
: 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;
|
if (disposed || busy) return;
|
||||||
const googleWasUnset = !status?.keys?.find(
|
const googleWasUnset = !status?.keys?.find(
|
||||||
(key) => key.id === 'google-maps',
|
(key) => key.id === 'google-maps',
|
||||||
)?.set;
|
)?.set;
|
||||||
busy = true;
|
busy = true;
|
||||||
applyButton?.setAttribute('aria-disabled', 'true');
|
applyButton?.setAttribute('aria-disabled', 'true');
|
||||||
say('Saving…');
|
say(t('Saving…'));
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/setup/keys', {
|
const response = await doFetch('/api/setup/keys', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -390,7 +524,7 @@ export async function initKeySetup({
|
|||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!response.ok || !payload.ok) {
|
if (!response.ok || !payload.ok) {
|
||||||
say(payload.error || `Save failed (${response.status}).`);
|
say(saveRefusalText(payload, response.status));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
for (const input of root.querySelectorAll('input[data-env-var]'))
|
||||||
@@ -420,11 +554,13 @@ export async function initKeySetup({
|
|||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
say(
|
say(t(doneMessageKey, { store: storeLabel() }));
|
||||||
`${doneVerb} ${storeLabel()}. Restarting — this page reloads itself.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
say(`Save failed: ${error?.message || error}`);
|
say(
|
||||||
|
t('Save failed: {detail}', {
|
||||||
|
detail: error?.message || error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
applyButton?.setAttribute('aria-disabled', 'false');
|
applyButton?.setAttribute('aria-disabled', 'false');
|
||||||
@@ -441,10 +577,13 @@ export async function initKeySetup({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (!Object.keys(updates).length) {
|
if (!Object.keys(updates).length) {
|
||||||
say('Paste at least one key first.');
|
say(t('Paste at least one key first.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await submitUpdates(updates, 'Saved to');
|
await submitUpdates(
|
||||||
|
updates,
|
||||||
|
msg('Saved to {store}. Restarting — this page reloads itself.'),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
chip.addEventListener('click', openDialog);
|
chip.addEventListener('click', openDialog);
|
||||||
@@ -462,12 +601,12 @@ export async function initKeySetup({
|
|||||||
if (current === 'oauth') {
|
if (current === 'oauth') {
|
||||||
writeStoredCloudVoiceAuthMode('api-key');
|
writeStoredCloudVoiceAuthMode('api-key');
|
||||||
syncCloudVoiceAuth();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
oauthBusy = true;
|
oauthBusy = true;
|
||||||
syncCloudVoiceAuth();
|
syncCloudVoiceAuth();
|
||||||
say('Checking local ChatGPT OAuth sign-in…');
|
say(t('Checking local ChatGPT OAuth sign-in…'));
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/realtime/oauth-status', {
|
const response = await doFetch('/api/realtime/oauth-status', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@@ -476,22 +615,25 @@ export async function initKeySetup({
|
|||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (lifetime.signal.aborted) return;
|
if (lifetime.signal.aborted) return;
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(
|
throw oauthCheckError(
|
||||||
payload.error || 'Could not check ChatGPT sign-in.',
|
payload.error,
|
||||||
|
t('Could not check ChatGPT sign-in.'),
|
||||||
);
|
);
|
||||||
if (payload.available) {
|
if (payload.available) {
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
syncCloudVoiceAuth();
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
|
t(
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
say(
|
say(
|
||||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
||||||
: 'Opening ChatGPT sign-in in your browser…',
|
: t('Opening ChatGPT sign-in in your browser…'),
|
||||||
);
|
);
|
||||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -504,7 +646,7 @@ export async function initKeySetup({
|
|||||||
say(
|
say(
|
||||||
loginPayload.error ||
|
loginPayload.error ||
|
||||||
payload.error ||
|
payload.error ||
|
||||||
'Could not start ChatGPT sign-in on this machine.',
|
t('Could not start ChatGPT sign-in on this machine.'),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -512,13 +654,17 @@ export async function initKeySetup({
|
|||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
syncCloudVoiceAuth();
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
|
t(
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
say(
|
say(
|
||||||
|
t(
|
||||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const available = await waitForChatGptOAuth({
|
const available = await waitForChatGptOAuth({
|
||||||
fetchImpl: doFetch,
|
fetchImpl: doFetch,
|
||||||
@@ -527,18 +673,22 @@ export async function initKeySetup({
|
|||||||
if (lifetime.signal.aborted) return;
|
if (lifetime.signal.aborted) return;
|
||||||
if (!available) {
|
if (!available) {
|
||||||
say(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
syncCloudVoiceAuth();
|
syncCloudVoiceAuth();
|
||||||
say(
|
say(
|
||||||
|
t(
|
||||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (lifetime.signal.aborted) return;
|
if (lifetime.signal.aborted) return;
|
||||||
say(`OAuth check failed: ${error?.message || error}`);
|
say(oauthCheckFailureText(error));
|
||||||
} finally {
|
} finally {
|
||||||
oauthBusy = false;
|
oauthBusy = false;
|
||||||
if (!disposed) syncCloudVoiceAuth();
|
if (!disposed) syncCloudVoiceAuth();
|
||||||
@@ -560,11 +710,11 @@ export async function initKeySetup({
|
|||||||
// a deliberate two-step the lure cannot pre-satisfy.
|
// a deliberate two-step the lure cannot pre-satisfy.
|
||||||
const ok =
|
const ok =
|
||||||
typeof globalThis.confirm !== 'function' ||
|
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;
|
if (!ok) return;
|
||||||
void submitUpdates(
|
void submitUpdates(
|
||||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||||
'Removed from',
|
msg('Removed from {store}. Restarting — this page reloads itself.'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+66
-8
@@ -6,29 +6,47 @@ import {
|
|||||||
stripKeylessBasemapFromHash,
|
stripKeylessBasemapFromHash,
|
||||||
bindKeySetupPlacement,
|
bindKeySetupPlacement,
|
||||||
waitForChatGptOAuth,
|
waitForChatGptOAuth,
|
||||||
|
initKeySetup,
|
||||||
} from './keySetup.js';
|
} from './keySetup.js';
|
||||||
|
import { describeUnsavedWork } from './i18n/index.js';
|
||||||
|
|
||||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
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 chip = { parentNode: home, hidden: true };
|
||||||
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
|
|
||||||
let moves = 0;
|
let moves = 0;
|
||||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
const toolbar = {
|
||||||
|
append: (node) => {
|
||||||
|
moves++;
|
||||||
|
node.parentNode = toolbar;
|
||||||
|
},
|
||||||
|
};
|
||||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||||
let notify, disconnected = false;
|
let notify,
|
||||||
|
disconnected = false;
|
||||||
class Observer {
|
class Observer {
|
||||||
constructor(callback) { notify = callback; }
|
constructor(callback) {
|
||||||
|
notify = callback;
|
||||||
|
}
|
||||||
observe(target, options) {
|
observe(target, options) {
|
||||||
assert.equal(target, theme);
|
assert.equal(target, theme);
|
||||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
||||||
}
|
}
|
||||||
disconnect() { disconnected = true; }
|
disconnect() {
|
||||||
|
disconnected = true;
|
||||||
}
|
}
|
||||||
const dispose = bindKeySetupPlacement({
|
}
|
||||||
|
const dispose = bindKeySetupPlacement(
|
||||||
|
{
|
||||||
documentElement: theme,
|
documentElement: theme,
|
||||||
getElementById: () => toolbar,
|
getElementById: () => toolbar,
|
||||||
defaultView: { MutationObserver: Observer },
|
defaultView: { MutationObserver: Observer },
|
||||||
}, chip, root);
|
},
|
||||||
|
chip,
|
||||||
|
);
|
||||||
assert.equal(chip.parentNode, home);
|
assert.equal(chip.parentNode, home);
|
||||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||||
theme.dataset.uiTheme = variant;
|
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: [] }) });
|
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
||||||
assert.equal(await pending, null);
|
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
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Key setup ("POWER UP") — the pure core.
|
* Key setup ("POWER UP") — the pure core.
|
||||||
*
|
*
|
||||||
* One registry and pure functions; its only import is the proxy-header check
|
* One registry and pure functions; it imports only the proxy-header check
|
||||||
* shared with the other local gates (localRequestGate.mjs). The dev server's
|
* 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)
|
* /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
|
* 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.
|
* unlocks, and how a .env line is written each live in exactly one place.
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
* behavior below unit-testable.
|
* behavior below unit-testable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { msg } from './i18n/marker.js';
|
||||||
import { hasProxySignals } from './localRequestGate.mjs';
|
import { hasProxySignals } from './localRequestGate.mjs';
|
||||||
|
|
||||||
/** Longest accepted key/token value. Real provider keys are all far shorter. */
|
/** 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([
|
export const KEY_SETUP_KEYS = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'google-maps',
|
id: 'google-maps',
|
||||||
title: 'GOOGLE MAPS',
|
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
|
||||||
unlocks: 'The photorealistic 3D planet, place search + Street View',
|
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
|
||||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||||
tier: 'metered',
|
tier: 'metered',
|
||||||
@@ -44,8 +46,10 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'google-maps-server',
|
id: 'google-maps-server',
|
||||||
title: 'GOOGLE MAPS — SERVER',
|
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
|
||||||
unlocks: 'Places context + Street View fallback; optional separate key',
|
unlocks: msg(
|
||||||
|
'Places context + Street View fallback; optional separate key',
|
||||||
|
),
|
||||||
getUrl:
|
getUrl:
|
||||||
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
|
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
|
||||||
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
|
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
|
||||||
@@ -54,40 +58,40 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
title: 'OPENAI',
|
title: 'OPENAI', // i18n-ignore: provider product name
|
||||||
unlocks: 'Voice control — API key or ChatGPT OAuth',
|
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||||
getUrl: 'https://platform.openai.com/api-keys',
|
getUrl: 'https://platform.openai.com/api-keys',
|
||||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||||
tier: 'metered',
|
tier: 'metered',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'aisstream',
|
id: 'aisstream',
|
||||||
title: 'AISSTREAM',
|
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||||
unlocks: 'Live ships, worldwide',
|
unlocks: msg('Live ships, worldwide'),
|
||||||
getUrl: 'https://aisstream.io',
|
getUrl: 'https://aisstream.io',
|
||||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'firms',
|
id: 'firms',
|
||||||
title: 'NASA FIRMS',
|
title: 'NASA FIRMS', // i18n-ignore: provider product name
|
||||||
unlocks: 'Live active-fire detections',
|
unlocks: msg('Live active-fire detections'),
|
||||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'tomtom',
|
id: 'tomtom',
|
||||||
title: 'TOMTOM',
|
title: 'TOMTOM', // i18n-ignore: provider product name
|
||||||
unlocks: 'Real live traffic (keyless runs a simulation)',
|
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||||
getUrl: 'https://my.tomtom.com/keys',
|
getUrl: 'https://my.tomtom.com/keys',
|
||||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'cesium-ion',
|
id: 'cesium-ion',
|
||||||
title: 'CESIUM ION',
|
title: 'CESIUM ION', // i18n-ignore: provider product name
|
||||||
unlocks: 'Bing imagery map stacks + world terrain',
|
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||||
getUrl: 'https://ion.cesium.com/tokens',
|
getUrl: 'https://ion.cesium.com/tokens',
|
||||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
@@ -95,17 +99,18 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'opensky',
|
id: 'opensky',
|
||||||
title: 'OPENSKY',
|
title: 'OPENSKY', // i18n-ignore: provider product name
|
||||||
unlocks: 'More flight-polling credits (anonymous works without)',
|
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||||
getUrl: 'https://opensky-network.org',
|
getUrl: 'https://opensky-network.org',
|
||||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'mapillary',
|
id: 'mapillary',
|
||||||
title: 'MAPILLARY',
|
title: 'MAPILLARY', // i18n-ignore: provider product name
|
||||||
unlocks:
|
unlocks: msg(
|
||||||
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||||
|
),
|
||||||
getUrl: 'https://www.mapillary.com/dashboard/developers',
|
getUrl: 'https://www.mapillary.com/dashboard/developers',
|
||||||
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
|
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
@@ -113,8 +118,8 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'launch-library',
|
id: 'launch-library',
|
||||||
title: 'LAUNCH LIBRARY',
|
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||||
unlocks: 'Higher space-missions request allowance',
|
unlocks: msg('Higher space-missions request allowance'),
|
||||||
getUrl: 'https://thespacedevs.com',
|
getUrl: 'https://thespacedevs.com',
|
||||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
@@ -262,7 +267,9 @@ export function isSharingEnabled(env = {}) {
|
|||||||
* preflight this server never answers, closing the simple-request CSRF
|
* preflight this server never answers, closing the simple-request CSRF
|
||||||
* write primitive.
|
* 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({
|
export function admitKeySetupRequest({
|
||||||
method,
|
method,
|
||||||
@@ -283,6 +290,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Provider Settings does not answer proxied requests',
|
error: 'Provider Settings does not answer proxied requests',
|
||||||
|
code: 'proxied',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
// Every sharing signal the launcher recognizes (scripts/pinokio-preflight.mjs)
|
||||||
@@ -300,6 +308,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Provider Settings is disabled while sharing is enabled',
|
error: 'Provider Settings is disabled while sharing is enabled',
|
||||||
|
code: 'sharing',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -310,6 +319,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Provider Settings answers only the machine running the server',
|
error: 'Provider Settings answers only the machine running the server',
|
||||||
|
code: 'not-loopback',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const authority = localAuthority(hostHeader, protocol);
|
const authority = localAuthority(hostHeader, protocol);
|
||||||
@@ -318,6 +328,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Provider Settings answers only local hostnames',
|
error: 'Provider Settings answers only local hostnames',
|
||||||
|
code: 'not-local-host',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -328,6 +339,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Provider Settings requires an exact local Origin',
|
error: 'Provider Settings requires an exact local Origin',
|
||||||
|
code: 'origin-required',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (origin !== undefined && origin !== null && origin !== '') {
|
if (origin !== undefined && origin !== null && origin !== '') {
|
||||||
@@ -335,7 +347,12 @@ export function admitKeySetupRequest({
|
|||||||
try {
|
try {
|
||||||
parsedOrigin = new URL(String(origin));
|
parsedOrigin = new URL(String(origin));
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
error: 'Unrecognized Origin refused',
|
||||||
|
code: 'origin-unrecognized',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const exactOrigin =
|
const exactOrigin =
|
||||||
parsedOrigin.username === '' &&
|
parsedOrigin.username === '' &&
|
||||||
@@ -349,6 +366,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 403,
|
status: 403,
|
||||||
error: 'Cross-origin requests are refused',
|
error: 'Cross-origin requests are refused',
|
||||||
|
code: 'cross-origin',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,6 +380,7 @@ export function admitKeySetupRequest({
|
|||||||
ok: false,
|
ok: false,
|
||||||
status: 415,
|
status: 415,
|
||||||
error: 'Content-Type must be application/json',
|
error: 'Content-Type must be application/json',
|
||||||
|
code: 'content-type',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -376,7 +395,19 @@ export function knownKeySetupEnvVars() {
|
|||||||
return names;
|
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) {
|
export function keySetupRequirement(id) {
|
||||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||||
if (!entry || entry.hidden) return '';
|
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:
|
* below safe to write without quoting rules. A `null` value means REMOVE:
|
||||||
* the writer comments the assignment back out, returning the file to its
|
* the writer comments the assignment back out, returning the file to its
|
||||||
* template state for that key.
|
* 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.
|
* @param {unknown} body Parsed JSON from the request.
|
||||||
*/
|
*/
|
||||||
export function validateKeySetupUpdates(body) {
|
export function validateKeySetupUpdates(body) {
|
||||||
@@ -465,38 +498,63 @@ export function validateKeySetupUpdates(body) {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Body must be a JSON object of {ENV_VAR: value}',
|
error: 'Body must be a JSON object of {ENV_VAR: value}',
|
||||||
|
code: 'not-object',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const entries = Object.entries(body);
|
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) {
|
if (entries.length > KEY_SETUP_UPDATE_LIMIT) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
|
error: `At most ${KEY_SETUP_UPDATE_LIMIT} keys per save`,
|
||||||
|
code: 'too-many',
|
||||||
|
params: { limit: KEY_SETUP_UPDATE_LIMIT },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const known = knownKeySetupEnvVars();
|
const known = knownKeySetupEnvVars();
|
||||||
const updates = {};
|
const updates = {};
|
||||||
for (const [name, raw] of entries) {
|
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) {
|
if (raw === null) {
|
||||||
updates[name] = null;
|
updates[name] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof raw !== 'string')
|
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();
|
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) {
|
if (value.length > KEY_SETUP_VALUE_LIMIT) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `${name} is longer than any real key (${KEY_SETUP_VALUE_LIMIT} max)`,
|
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)) {
|
if (!/^[\x21-\x7e]+$/.test(value)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `${name} may only contain printable characters with no spaces`,
|
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
|
// Reject the dotenv metacharacters that would round-trip WRONG when written
|
||||||
@@ -508,6 +566,8 @@ export function validateKeySetupUpdates(body) {
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
|
error: `${name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)`,
|
||||||
|
code: 'invalid-char',
|
||||||
|
params: { name },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
updates[name] = value;
|
updates[name] = value;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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\.$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { msg, t, tn } from '../../i18n/index.js';
|
||||||
|
import { translateFeedError } from '../../ui/statusText.js';
|
||||||
import {
|
import {
|
||||||
AIR_QUALITY_BANDS,
|
AIR_QUALITY_BANDS,
|
||||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||||
@@ -109,7 +111,7 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (request.signal.aborted || _request !== request) return false;
|
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);
|
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
@@ -214,34 +216,41 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
|
|||||||
(max, reading) => Math.max(max, reading.observedMs || 0),
|
(max, reading) => Math.max(max, reading.observedMs || 0),
|
||||||
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 {
|
return {
|
||||||
readout: true,
|
readout: true,
|
||||||
summary: {
|
summary: {
|
||||||
label: provider.label,
|
label: t(provider.label),
|
||||||
coverage: provider.coverage,
|
coverage: t(provider.coverage),
|
||||||
validTime: newest ? new Date(newest).toISOString() : undefined,
|
validTime: newest ? new Date(newest).toISOString() : undefined,
|
||||||
detail: `${provider.network} · ${provider.scale}`,
|
detail: `${provider.network} · ${provider.scale}`,
|
||||||
status: _lastError
|
status: _lastError
|
||||||
? _lastError
|
? translateFeedError(_lastError)
|
||||||
: _count === 0
|
: _count === 0
|
||||||
? _lastUpdate === null
|
? _lastUpdate === null
|
||||||
? 'No readings available'
|
? t('No readings available')
|
||||||
: 'No current readings'
|
: t('No current readings')
|
||||||
: `${_count} stations reporting`,
|
: tn(
|
||||||
|
_count,
|
||||||
|
'{count} station reporting',
|
||||||
|
'{count} stations reporting',
|
||||||
|
),
|
||||||
units: provider.scale,
|
units: provider.scale,
|
||||||
},
|
},
|
||||||
legend: AIR_QUALITY_BANDS.map((band) => ({
|
legend: AIR_QUALITY_BANDS.map((band) => ({
|
||||||
color: band.color,
|
color: band.color,
|
||||||
label: band.label,
|
label: t(band.label),
|
||||||
})),
|
})),
|
||||||
legendCategorical: true,
|
legendCategorical: true,
|
||||||
list: {
|
list: {
|
||||||
ariaLabel: 'Highest air-quality readings in view',
|
ariaLabel: t('Highest air-quality readings in view'),
|
||||||
items: inView.map((reading, index) => ({
|
items: inView.map((reading, index) => ({
|
||||||
id: reading.id,
|
id: reading.id,
|
||||||
ordinal: index + 1,
|
ordinal: index + 1,
|
||||||
lead: `${reading.scale} ${reading.label}`,
|
lead: `${reading.scale} ${reading.label}`,
|
||||||
text: `${reading.name} · ${reading.risk}`,
|
text: `${reading.name} · ${t(reading.risk)}`,
|
||||||
accent: reading.color,
|
accent: reading.color,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,13 +15,36 @@
|
|||||||
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
||||||
* later provider registers against.
|
* 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. */
|
/** Band identifiers shared across providers, in ascending severity. */
|
||||||
export const AIR_QUALITY_BANDS = Object.freeze([
|
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||||
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
|
Object.freeze({
|
||||||
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
|
id: 'low',
|
||||||
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
|
label: msg('Low'),
|
||||||
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
|
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]));
|
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({
|
eccc: Object.freeze({
|
||||||
id: 'eccc',
|
id: 'eccc',
|
||||||
scale: 'AQHI',
|
scale: 'AQHI',
|
||||||
label: 'Air quality',
|
label: msg('Air quality'),
|
||||||
network: 'Environment and Climate Change Canada',
|
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. */
|
/** ECCC's published band thresholds, evaluated in order. */
|
||||||
bands: Object.freeze([
|
bands: Object.freeze([
|
||||||
Object.freeze({ id: 'low', max: 3 }),
|
Object.freeze({ id: 'low', max: 3 }),
|
||||||
@@ -161,8 +184,7 @@ export function valueLabel(providerId, value) {
|
|||||||
|
|
||||||
/** Plain-language risk phrase for a band id. */
|
/** Plain-language risk phrase for a band id. */
|
||||||
export function bandRiskText(bandId) {
|
export function bandRiskText(bandId) {
|
||||||
const band = airQualityBandById(bandId);
|
return airQualityBandById(bandId)?.risk ?? null;
|
||||||
return band ? `${band.label} health risk` : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.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 COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
||||||
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
||||||
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
||||||
@@ -47,7 +51,7 @@ export function createEcccAirQualitySource({
|
|||||||
const abort = () => controller.abort(signal.reason);
|
const abort = () => controller.abort(signal.reason);
|
||||||
signal?.addEventListener('abort', abort, { once: true });
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => controller.abort(new Error('ECCC request timed out')),
|
() => controller.abort(new Error(msg('ECCC request timed out'))),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -82,14 +86,15 @@ export function createEcccAirQualitySource({
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const next = normalizeAqhiStations(payload);
|
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.
|
// An empty refresh must not wipe a good catalog.
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
stations = next;
|
stations = next;
|
||||||
stationsFetchedAt = clock;
|
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(
|
const payload = await getJson(
|
||||||
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
||||||
@@ -97,7 +102,8 @@ export function createEcccAirQualitySource({
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const readings = normalizeAqhiObservations(payload, stations, clock);
|
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;
|
return readings;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+34
-20
@@ -18,6 +18,7 @@ import {
|
|||||||
alprCreditMarkup,
|
alprCreditMarkup,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
import { createAlprPresentation } from './presentation.js';
|
import { createAlprPresentation } from './presentation.js';
|
||||||
|
import { t, tn, msg } from '../../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Own one layer's requests, records, display and viewer subscriptions.
|
* Own one layer's requests, records, display and viewer subscriptions.
|
||||||
@@ -275,7 +276,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
state.stale = Boolean(state.lastUpdate);
|
state.stale = Boolean(state.lastUpdate);
|
||||||
setAlprStatus(
|
setAlprStatus(
|
||||||
'unavailable',
|
'unavailable',
|
||||||
error?.message || 'Camera source temporarily unavailable',
|
error?.message || msg('Camera source temporarily unavailable'),
|
||||||
);
|
);
|
||||||
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
id: LAYER_ID,
|
id: LAYER_ID,
|
||||||
name: 'ALPR Cameras',
|
name: 'ALPR Cameras',
|
||||||
icon: '📷',
|
icon: '📷',
|
||||||
source: source.label || 'Mapped camera locations',
|
// English in layer state; the layer row translates it.
|
||||||
|
source: source.label || msg('Mapped camera locations'),
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
chips: [
|
chips: [
|
||||||
{
|
{
|
||||||
id: 'find-camera',
|
id: 'find-camera',
|
||||||
label: 'SHOW NEAREST',
|
label: t('SHOW NEAREST'),
|
||||||
title: state.viewer?.trackedEntity
|
title: state.viewer?.trackedEntity
|
||||||
? 'Stop following the current object before navigating to a camera'
|
? t(
|
||||||
: 'Move to the nearest loaded camera and show its details',
|
'Stop following the current object before navigating to a camera',
|
||||||
|
)
|
||||||
|
: t('Move to the nearest loaded camera and show its details'),
|
||||||
disabled:
|
disabled:
|
||||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||||
onClick: focusNearest,
|
onClick: focusNearest,
|
||||||
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
],
|
],
|
||||||
legend: [
|
legend: [
|
||||||
{
|
{
|
||||||
label: 'Camera badges',
|
label: t('Camera badges'),
|
||||||
color: ALPR_COLOR,
|
color: ALPR_COLOR,
|
||||||
count,
|
count,
|
||||||
blurb:
|
blurb: t(
|
||||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
count: state.dataSource?.entities.values.length || 0,
|
count: state.dataSource?.entities.values.length || 0,
|
||||||
countLabel:
|
countLabel:
|
||||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
? Number.isInteger(state.onScreen)
|
||||||
Number.isInteger(state.onScreen)
|
? tn(
|
||||||
? ` · ${state.onScreen} on screen`
|
state.dataSource?.entities.values.length || 0,
|
||||||
: ''
|
'{count} nearby · {onScreen} on screen',
|
||||||
}`
|
'{count} nearby · {onScreen} on screen',
|
||||||
|
{ onScreen: state.onScreen },
|
||||||
|
)
|
||||||
|
: tn(
|
||||||
|
state.dataSource?.entities.values.length || 0,
|
||||||
|
'{count} nearby',
|
||||||
|
'{count} nearby',
|
||||||
|
)
|
||||||
: '',
|
: '',
|
||||||
noCoverage: Boolean(state.noCoverage),
|
noCoverage: Boolean(state.noCoverage),
|
||||||
lastUpdate: state.lastUpdate,
|
lastUpdate: state.lastUpdate,
|
||||||
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
|||||||
: 0,
|
: 0,
|
||||||
loadingLabel: state.loading
|
loadingLabel: state.loading
|
||||||
? state.retrying
|
? state.retrying
|
||||||
? 'retrying mapped ALPR cameras'
|
? t('retrying mapped ALPR cameras')
|
||||||
: 'loading mapped ALPR cameras'
|
: t('loading mapped ALPR cameras')
|
||||||
: state.noCoverage
|
: state.noCoverage
|
||||||
? 'No ALPR data for this area — US and Canada only'
|
? t('No ALPR data for this area — US and Canada only')
|
||||||
: state.status === 'zoom-in'
|
: state.status === 'zoom-in'
|
||||||
? 'Zoom in to load mapped cameras'
|
? t('Zoom in to load mapped cameras')
|
||||||
: [
|
: [
|
||||||
state.stale ? 'Showing cached locations' : '',
|
state.stale ? t('Showing cached locations') : '',
|
||||||
state.saturated || state.renderSaturated
|
state.saturated || state.renderSaturated
|
||||||
? 'Coverage limited — zoom in'
|
? t('Coverage limited — zoom in')
|
||||||
|
: '',
|
||||||
|
state.status === 'empty'
|
||||||
|
? t('No ALPR data for this area')
|
||||||
: '',
|
: '',
|
||||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
|
||||||
state.status === 'ready' && state.onScreen === 0
|
state.status === 'ready' && state.onScreen === 0
|
||||||
? 'None on screen — nearby cameras are outside the view'
|
? t('None on screen — nearby cameras are outside the view')
|
||||||
: '',
|
: '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MARKER_SCALE_BY_DISTANCE,
|
MARKER_SCALE_BY_DISTANCE,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { boxContains } from './model.js';
|
import { boxContains } from './model.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import { createAlprFloorResolver } from './floors.js';
|
import { createAlprFloorResolver } from './floors.js';
|
||||||
import { createAlprOverlay } from './overlay.js';
|
import { createAlprOverlay } from './overlay.js';
|
||||||
import {
|
import {
|
||||||
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
|||||||
layerName: 'ALPR Cameras',
|
layerName: 'ALPR Cameras',
|
||||||
source:
|
source:
|
||||||
source.attribution?.description || source.label || 'Camera source',
|
source.attribution?.description || source.label || 'Camera source',
|
||||||
label: 'ALPR camera',
|
label: t('ALPR camera'),
|
||||||
latitude: record.latitude,
|
latitude: record.latitude,
|
||||||
longitude: record.longitude,
|
longitude: record.longitude,
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
} from '../../sources/vectorTiles.js';
|
} from '../../sources/vectorTiles.js';
|
||||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||||
import { decodeAlprTile } from './tileRecords.js';
|
import { decodeAlprTile } from './tileRecords.js';
|
||||||
|
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||||
|
// because AI tool services import this module outside the browser.
|
||||||
|
const msg = (text) => text;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
||||||
@@ -136,7 +139,7 @@ export function createAlprTileSource({
|
|||||||
const metadata = await source.getMetadata(signal);
|
const metadata = await source.getMetadata(signal);
|
||||||
const [west, south, east, north] = metadata.bounds || [];
|
const [west, south, east, north] = metadata.bounds || [];
|
||||||
if (![west, south, east, north].every(Number.isFinite))
|
if (![west, south, east, north].every(Number.isFinite))
|
||||||
throw new Error('Camera coverage unavailable');
|
throw new Error(msg('Camera coverage unavailable'));
|
||||||
if (
|
if (
|
||||||
box.east < west ||
|
box.east < west ||
|
||||||
box.west > east ||
|
box.west > east ||
|
||||||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
|
|||||||
for (const source of sources) source.clear();
|
for (const source of sources) source.clear();
|
||||||
precision.clear();
|
precision.clear();
|
||||||
},
|
},
|
||||||
label: 'OpenStreetMap · community mapped',
|
label: msg('OpenStreetMap · community mapped'),
|
||||||
attribution: {
|
attribution: {
|
||||||
name: 'OpenStreetMap',
|
name: 'OpenStreetMap',
|
||||||
description: '© OpenStreetMap contributors, ODbL',
|
description: '© OpenStreetMap contributors, ODbL',
|
||||||
@@ -242,7 +245,7 @@ export function createOverpassAlprSource({
|
|||||||
// The shared proxy already rejects query errors. Validate here too so a
|
// The shared proxy already rejects query errors. Validate here too so a
|
||||||
// malformed or partial response never becomes an authoritative empty map.
|
// malformed or partial response never becomes an authoritative empty map.
|
||||||
if (!Array.isArray(payload?.elements) || payload.remark) {
|
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 {
|
return {
|
||||||
records: [
|
records: [
|
||||||
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetch: fetchAlprNodes,
|
fetch: fetchAlprNodes,
|
||||||
label: 'OpenStreetMap · community mapped',
|
label: msg('OpenStreetMap · community mapped'),
|
||||||
attribution: {
|
attribution: {
|
||||||
name: 'OpenStreetMap',
|
name: 'OpenStreetMap',
|
||||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
formatAwarenessLabel,
|
formatAwarenessLabel,
|
||||||
formatAwarenessDistance,
|
|
||||||
AWARENESS_RADIUS_M,
|
AWARENESS_RADIUS_M,
|
||||||
} from '../../data/militaryAwarenessEngine.js';
|
} 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';
|
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||||
|
|
||||||
/** Format a cohort total without presenting a retained cap as exact. */
|
/** Format a cohort total without presenting a retained cap as exact. */
|
||||||
@@ -14,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
|
|||||||
: count;
|
: count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The count as the panel shows it, in the UI language. */
|
||||||
|
function presentAwarenessCount(summary = {}) {
|
||||||
|
if (!Number.isFinite(summary.count)) return '?';
|
||||||
|
const count = formatNumber(summary.count);
|
||||||
|
return summary.complete === false || summary.truncated === true
|
||||||
|
? t('At least {count}', { count })
|
||||||
|
: count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
|
||||||
|
function presentAwarenessDistance(meters) {
|
||||||
|
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||||
|
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
|
||||||
|
const digits = meters < 10000 ? 1 : 0;
|
||||||
|
return formatQuantity(meters / 1000, 'km', {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cohort's reason, kept English in data, shown in the UI language. */
|
||||||
|
function presentAwarenessReason(summary = {}) {
|
||||||
|
return summary.reasonTemplate
|
||||||
|
? t(summary.reasonTemplate, summary.reasonValues)
|
||||||
|
: translateKnown(summary.reason || '');
|
||||||
|
}
|
||||||
|
|
||||||
export function createPanel({ state: layerState, services, parts, source }) {
|
export function createPanel({ state: layerState, services, parts, source }) {
|
||||||
function ensurePanel() {
|
function ensurePanel() {
|
||||||
if (layerState.panel) return layerState.panel;
|
if (layerState.panel) return layerState.panel;
|
||||||
@@ -56,8 +84,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
function hidePanel() {
|
function hidePanel() {
|
||||||
if (layerState.panel) {
|
if (layerState.panel) {
|
||||||
const markup = `<div class="military-awareness-standby">
|
const markup = `<div class="military-awareness-standby">
|
||||||
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
|
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
|
||||||
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
|
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
layerState.panel.hidden = false;
|
layerState.panel.hidden = false;
|
||||||
if (layerState.panelMarkup !== markup) {
|
if (layerState.panelMarkup !== markup) {
|
||||||
@@ -70,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function rowHtml(cohort) {
|
function rowHtml(cohort) {
|
||||||
const summary = cohort.summary;
|
const summary = cohort.summary;
|
||||||
const count = formatAwarenessCount(summary);
|
const count = presentAwarenessCount(summary);
|
||||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
const nearest = summary.nearest
|
const nearest = summary.nearest
|
||||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||||
@@ -78,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
const label = formatAwarenessLabel(item);
|
const label = formatAwarenessLabel(item);
|
||||||
const targetId = item.icao24 || item.mmsi || item.id;
|
const targetId = item.icao24 || item.mmsi || item.id;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
return `<li><span class="military-awareness-target unavailable" aria-label="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;
|
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="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
const pageCount = Math.max(
|
const pageCount = Math.max(
|
||||||
@@ -95,17 +123,17 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
|
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
||||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigationControlsHtml() {
|
function navigationControlsHtml() {
|
||||||
const canPrevious = layerState.navigationIndex > 0;
|
const canPrevious = layerState.navigationIndex > 0;
|
||||||
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
|
return `<div class="military-awareness-controls" role="group" aria-label="${t('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="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
|
||||||
<button type="button" data-awareness-action="focus">FOCUS</button>
|
<button type="button" data-awareness-action="focus">${t('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>
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
function namedAreasHtml(names) {
|
function namedAreasHtml(names) {
|
||||||
return names?.length
|
return names?.length
|
||||||
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +211,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
|||||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||||
const panel = ensurePanel();
|
const panel = ensurePanel();
|
||||||
const { subject, cohorts } = layerState.results;
|
const { subject, cohorts } = layerState.results;
|
||||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
|
||||||
${namedAreasHtml(subject.memberNames)}
|
${namedAreasHtml(subject.memberNames)}
|
||||||
${navigationControlsHtml()}
|
${navigationControlsHtml()}
|
||||||
${cohorts.map(rowHtml).join('')}
|
${cohorts.map(rowHtml).join('')}
|
||||||
<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;
|
panel.hidden = false;
|
||||||
if (layerState.panelMarkup !== markup) {
|
if (layerState.panelMarkup !== markup) {
|
||||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||||
|
|
||||||
|
// The reason stays English for the voice analyst; the Contacts panel shows it
|
||||||
|
// in the UI language from `reasonTemplate`.
|
||||||
|
const msg = (text) => text;
|
||||||
|
const fillTemplate = (template, values = {}) =>
|
||||||
|
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
|
||||||
|
const withReason = (summary, template, values) => ({
|
||||||
|
...summary,
|
||||||
|
reason: fillTemplate(template, values),
|
||||||
|
reasonTemplate: template,
|
||||||
|
reasonValues: values,
|
||||||
|
});
|
||||||
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||||
|
|
||||||
export function createQueries({ state: layerState, services, parts, source }) {
|
export function createQueries({ state: layerState, services, parts, source }) {
|
||||||
@@ -119,19 +131,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
|||||||
// A subject window is its own bounded area: name it instead of the viewport.
|
// A subject window is its own bounded area: name it instead of the viewport.
|
||||||
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
||||||
const km = Math.round(coverage.radiusM / 1000);
|
const km = Math.round(coverage.radiusM / 1000);
|
||||||
return {
|
return withReason(
|
||||||
...summary,
|
summary,
|
||||||
reason: summary.count
|
summary.count
|
||||||
? `mapped matches within ${km} km of the subject`
|
? msg('mapped matches within {km} km of the subject')
|
||||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
: msg('none mapped within {km} km; not a complete 250 km survey'),
|
||||||
};
|
{ km },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return withReason(
|
||||||
...summary,
|
summary,
|
||||||
reason: summary.count
|
summary.count
|
||||||
? 'mapped matches from the loaded viewport'
|
? msg('mapped matches from the loaded viewport')
|
||||||
: 'viewport feed is not a complete 250 km survey',
|
: msg('viewport feed is not a complete 250 km survey'),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
createCyberSonarSampler,
|
createCyberSonarSampler,
|
||||||
isCyberContactSonarActive as isCyberSonarActive,
|
isCyberContactSonarActive as isCyberSonarActive,
|
||||||
@@ -137,7 +138,9 @@ export function createRendering({
|
|||||||
'--compass-rotation',
|
'--compass-rotation',
|
||||||
`${-headingDeg}deg`,
|
`${-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.left = `${geometry.centerX}px`;
|
||||||
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
||||||
for (const label of layerState.compassLabels) {
|
for (const label of layerState.compassLabels) {
|
||||||
@@ -221,10 +224,14 @@ export function createRendering({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const bearingText = Number.isFinite(bearing)
|
const bearingText = Number.isFinite(bearing)
|
||||||
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
|
? t('BRG {value}°', {
|
||||||
: 'BRG —';
|
value: String(Math.round(bearing)).padStart(3, '0'),
|
||||||
|
})
|
||||||
|
: t('BRG —');
|
||||||
const courseText = Number.isFinite(item.track)
|
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._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||||
marker.hidden = false;
|
marker.hidden = false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AWARENESS_RELATIONSHIP,
|
AWARENESS_RELATIONSHIP,
|
||||||
} from '../../data/militaryAwarenessEngine.js';
|
} from '../../data/militaryAwarenessEngine.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
AWARENESS_QUERY_LIMIT,
|
AWARENESS_QUERY_LIMIT,
|
||||||
SOURCE_LABEL,
|
SOURCE_LABEL,
|
||||||
@@ -10,6 +11,19 @@ import {
|
|||||||
AWARENESS_PAGE_SIZE,
|
AWARENESS_PAGE_SIZE,
|
||||||
} from './policy.js';
|
} 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 }) {
|
export function createSubject({ state: layerState, services, parts, source }) {
|
||||||
const aisLiveVesselsLayer = services.vessels;
|
const aisLiveVesselsLayer = services.vessels;
|
||||||
const militaryInstallationsLayer = services.installations;
|
const militaryInstallationsLayer = services.installations;
|
||||||
@@ -78,7 +92,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
cohorts: [
|
cohorts: [
|
||||||
{
|
{
|
||||||
id: 'flights',
|
id: 'flights',
|
||||||
label: 'Flights',
|
label: t('Flights'),
|
||||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||||
provenance: retainedProvenance(
|
provenance: retainedProvenance(
|
||||||
'flights',
|
'flights',
|
||||||
@@ -97,7 +111,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'military',
|
id: 'military',
|
||||||
label: 'Military flights',
|
label: t('Military flights'),
|
||||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||||
provenance: retainedProvenance(
|
provenance: retainedProvenance(
|
||||||
'military',
|
'military',
|
||||||
@@ -116,7 +130,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ais-live-vessels',
|
id: 'ais-live-vessels',
|
||||||
label: 'AIS vessels',
|
label: t('AIS vessels'),
|
||||||
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
||||||
provenance: retainedProvenance(
|
provenance: retainedProvenance(
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
@@ -131,7 +145,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'military-installations',
|
id: 'military-installations',
|
||||||
label: 'Mapped installations',
|
label: t('Mapped installations'),
|
||||||
source:
|
source:
|
||||||
installationsState.stats.source ||
|
installationsState.stats.source ||
|
||||||
SOURCE_LABEL['military-installations'],
|
SOURCE_LABEL['military-installations'],
|
||||||
@@ -142,7 +156,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
SOURCE_LABEL['military-installations'],
|
SOURCE_LABEL['military-installations'],
|
||||||
),
|
),
|
||||||
coverage:
|
coverage:
|
||||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
|
||||||
|
t('CURRENT VIEWPORT ONLY'),
|
||||||
summary: parts.queries.summarizeInstallationViewport(
|
summary: parts.queries.summarizeInstallationViewport(
|
||||||
installations,
|
installations,
|
||||||
installationsState,
|
installationsState,
|
||||||
|
|||||||
Vendored
+6
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { msg, t } from '../../i18n/index.js';
|
||||||
import { STATUS_POLL_MS } from './policy.js';
|
import { STATUS_POLL_MS } from './policy.js';
|
||||||
|
|
||||||
export function createControls({ state: layerState, services, parts, source }) {
|
export function createControls({ state: layerState, services, parts, source }) {
|
||||||
@@ -67,8 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
if (layerState._loading) {
|
if (layerState._loading) {
|
||||||
stats.loadingLabel =
|
stats.loadingLabel =
|
||||||
layerState._activeCityIds.size > 0
|
layerState._activeCityIds.size > 0
|
||||||
? `syncing ${layerState._activeCityIds.size} city feeds...`
|
? t('syncing {count} city feeds...', {
|
||||||
: 'scanning nearby systems...';
|
count: layerState._activeCityIds.size,
|
||||||
|
})
|
||||||
|
: t('scanning nearby systems...');
|
||||||
}
|
}
|
||||||
// A failed city is only the layer's failure when it left nothing on the
|
// 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
|
// 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.
|
// points remain, they read STALE.
|
||||||
if (layerState._failedCityIds.size > 0) {
|
if (layerState._failedCityIds.size > 0) {
|
||||||
if (layerState._stationRenderMap.size === 0) {
|
if (layerState._stationRenderMap.size === 0) {
|
||||||
stats.error = 'GBFS fetch error';
|
stats.error = msg('GBFS fetch error');
|
||||||
} else {
|
} else {
|
||||||
stats.degraded = true;
|
stats.degraded = true;
|
||||||
const allFailed = Array.from(layerState._activeCityIds).every(
|
const allFailed = Array.from(layerState._activeCityIds).every(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
import {
|
import {
|
||||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||||
@@ -22,7 +23,9 @@ export function createSelection({
|
|||||||
const stationName = String(record?.stationName || '').trim();
|
const stationName = String(record?.stationName || '').trim();
|
||||||
const stationLabel =
|
const stationLabel =
|
||||||
stationName ||
|
stationName ||
|
||||||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
|
(record?.stationId
|
||||||
|
? t('Station {id}', { id: record.stationId })
|
||||||
|
: t('Station'));
|
||||||
const bikes = Number.isFinite(record?.bikesAvailable)
|
const bikes = Number.isFinite(record?.bikesAvailable)
|
||||||
? record.bikesAvailable
|
? record.bikesAvailable
|
||||||
: '?';
|
: '?';
|
||||||
@@ -33,14 +36,18 @@ export function createSelection({
|
|||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
stationLabel,
|
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
|
// Append warnings for stations that are offline or partially non-operational
|
||||||
const abnormal = [];
|
const abnormal = [];
|
||||||
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
|
if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
|
||||||
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
|
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
|
||||||
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
|
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
|
||||||
if (abnormal.length > 0) {
|
if (abnormal.length > 0) {
|
||||||
lines.push(abnormal.join(' · '));
|
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
Reference in new issue
Block a user