mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge origin/i18n-spanish: whole-interface translation meets KartaView
This branch moved twice at once: upstream merged KartaView (#1121, a third keyless street-level provider sharing a new lineCoverage.js), and the remote i18n-spanish gained the whole-interface translation work (1993 catalog entries, translateKnown, data-i18n-runtime, the unmarked- string detector in npm run i18n) built on the Street View merge. Integration keeps the remote's mechanisms and re-adds KartaView on top: - presentMeta: remote's translateHint for coverage hints, plus the KartaView count line as its own literal tn pair (Russian plurals need the noun inside the template; keyed off coverageLabel.noun with the sequences pair as the default). - uiState: upstream's highlightsSequence legend gate (KartaView's lines do not highlight) with t('Selected'). - layer-panels.html: remote's data-i18n/data-i18n-runtime hooks plus the sl-since-note wiring KartaView's upload-date SINCE note needs. - kartaview viewer: the photo-navigation aria-label and img alt go through t(), as the remote's unmarked-string detector requires. - catalogs: remote's 1987-entry base (its refined keys supersede this branch's earlier Street View wording) plus the KartaView entries: coverage hints, the upload-date note, the streets count line (with Russian one/few/many/other forms) and the viewer strings. npm run i18n: complete, no unmarked strings. npm test: 6520/6522 (the one failure is the pre-existing environmental OPENAI_API_KEY leak in devFreshDotenv). Build passes.
This commit is contained in:
commit
32b11e27de
176 files changed
+9713
-2210
No files matched your search
+3
-1
@@ -21,7 +21,9 @@
|
||||
switch in the Visual Presets dock, then the browser language; English stays
|
||||
the default and the fallback. `npm run i18n`, also run by `npm test`, fails on
|
||||
missing, unused or broken translations. Built on the i18n foundation by
|
||||
@meirm (#601), with the Russian catalog started from #312.
|
||||
@meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is
|
||||
not marked at all, so the whole interface, including surfaces added after
|
||||
the original extraction, is translated (1,679 messages).
|
||||
|
||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||
|
||||
@@ -3065,8 +3065,11 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
|
||||
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||
station names) and attribution.
|
||||
- **Gaps.** Strings added after #601's extraction (for example the Street Level
|
||||
panel) are still raw English; there is no lint for unmarked UI text yet.
|
||||
- **Unmarked text.** The same check lists UI text that is not marked at all
|
||||
(markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`)
|
||||
and fails on it. Text kept on purpose says why: `translate="no"`,
|
||||
`data-i18n-runtime`, or `// i18n-ignore: <reason>`. `src/tools/` is skipped:
|
||||
it writes for AI clients, not the page.
|
||||
|
||||
## Canonical Docs Order
|
||||
|
||||
|
||||
+29
-9
@@ -43,13 +43,13 @@ label.textContent = t(FEED_STATE_LABELS[state]);
|
||||
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
|
||||
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
|
||||
- **Numbers and dates** go through `formatNumber`, `formatDate` and `formatRelativeTime` from `src/i18n/index.js`, which follow the active language.
|
||||
- **Identical English means identical translation.** If two places need different words, make the English differ.
|
||||
- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
|
||||
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
|
||||
|
||||
## After you add or change text
|
||||
|
||||
```sh
|
||||
npm run i18n # shows what each language now lacks or no longer uses
|
||||
npm run i18n # what each language lacks or no longer uses, and unmarked UI text
|
||||
npm run i18n -- --missing ru # the missing ru entries as JSON
|
||||
npm run i18n -- --prune # drop entries whose English no longer exists
|
||||
```
|
||||
@@ -58,12 +58,32 @@ Changing the English changes the key, so the old translation goes unused and the
|
||||
|
||||
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock.
|
||||
|
||||
A translation that is exactly the English ("Bloom" → "Bloom") is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
|
||||
|
||||
## Text the check reports as unmarked
|
||||
|
||||
`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check:
|
||||
|
||||
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
|
||||
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties.
|
||||
|
||||
Fix it by marking the text. When the text must stay as it is, say so where it lives:
|
||||
|
||||
| Case | How |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
|
||||
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
|
||||
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
|
||||
|
||||
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
|
||||
|
||||
## Where things live
|
||||
|
||||
| File | What it holds |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
||||
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
|
||||
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
||||
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages and checks the catalogs |
|
||||
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
|
||||
| File | What it holds |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
||||
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
|
||||
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
||||
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
|
||||
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
|
||||
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
|
||||
+1
-1
@@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla
|
||||
npm run i18n -- --prune # drop entries the code no longer uses
|
||||
```
|
||||
|
||||
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, or when a plural entry lacks a form.
|
||||
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation is still the English text. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`.
|
||||
|
||||
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ The `many` form is the `other` text with «de» after the number: «{count} de c
|
||||
| retry / retrying | reintento / reintentando |
|
||||
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
|
||||
| Provider Settings | Ajustes de proveedores |
|
||||
| ON / OFF (state chips) | ACTIVADO / DESACTIVADO |
|
||||
| ON / OFF (state chips) | ACTIVO / INACTIVO (short enough for the layer pill) |
|
||||
| enable / disable | activar / desactivar |
|
||||
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
|
||||
| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares |
|
||||
@@ -61,10 +61,15 @@ The `many` form is the `other` text with «de» after the number: «{count} de c
|
||||
| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura |
|
||||
| feather / fade | difuminado / desvanecimiento |
|
||||
| detection overlay | superposición de detección |
|
||||
| pin (Draw tool) | marcador |
|
||||
| Bloom / Glow (visual effect) | resplandor |
|
||||
| Cyber (HUD layout, effect) | Ciber |
|
||||
| heading, course / altitude / ground speed | rumbo / altitud / velocidad respecto al suelo (short: VEL. SUELO) |
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
|
||||
| PREV / NEXT | ANT. / SIG. |
|
||||
| Previous / Next (button text) | Anterior / Siguiente |
|
||||
| L / R (relative bearing) | I / D |
|
||||
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTICIAS / LOCAL |
|
||||
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
|
||||
| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO |
|
||||
| click / click to … | haz clic / clic para … |
|
||||
+39
-30
@@ -21,36 +21,45 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
|
||||
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
|
||||
| `other` | fractions: 1.5, 2.25… | 1,5 камеры |
|
||||
|
||||
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}».
|
||||
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}».
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | Русский |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| layer / data layer | слой / слой данных |
|
||||
| feed | поток данных (short: поток) |
|
||||
| source / data source | источник / источник данных |
|
||||
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
||||
| SIMULATED | СИМУЛЯЦИЯ |
|
||||
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
||||
| retry | повтор |
|
||||
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
||||
| Provider Settings | Настройки провайдеров |
|
||||
| globe / map / map source | глобус / карта / источник карты |
|
||||
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
|
||||
| vessel, ship / vessels | судно / суда |
|
||||
| satellite | спутник |
|
||||
| launch (rocket) / space mission | пуск / космическая миссия |
|
||||
| contact / tracking | контакт / отслеживание |
|
||||
| installation, site | объект |
|
||||
| camera / CCTV camera | камера / камера CCTV |
|
||||
| earthquake / active fire | землетрясение / активный пожар |
|
||||
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
|
||||
| briefing / panel / dock | брифинг / панель / док |
|
||||
| preset / visual preset | пресет / визуальный пресет |
|
||||
| Scope (circular viewport mask) | прицел |
|
||||
| heading / altitude / ground speed | курс / высота / путевая скорость |
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
||||
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
||||
| Previous / Next (button text) | Назад / Далее |
|
||||
| L / R (relative bearing) | Л / П |
|
||||
| English | Русский |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| layer / data layer | слой / слой данных |
|
||||
| feed | поток данных (short: поток) |
|
||||
| source / data source | источник / источник данных |
|
||||
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
||||
| SIMULATED | СИМУЛЯЦИЯ |
|
||||
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
||||
| retry | повтор |
|
||||
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
||||
| Provider Settings | Настройки провайдеров |
|
||||
| globe / map / map source | глобус / карта / источник карты |
|
||||
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
|
||||
| vessel, ship / vessels | судно / суда |
|
||||
| satellite | спутник |
|
||||
| launch (rocket) / space mission | пуск / космическая миссия |
|
||||
| contact / tracking | контакт / отслеживание |
|
||||
| installation, site | объект |
|
||||
| camera / CCTV camera | камера / камера CCTV |
|
||||
| earthquake / active fire | землетрясение / активный пожар |
|
||||
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
|
||||
| briefing / panel / dock | брифинг / панель / док |
|
||||
| preset / visual preset | пресет / визуальный пресет |
|
||||
| Scope (circular viewport mask) | прицел |
|
||||
| heading / altitude / ground speed | курс / высота / путевая скорость |
|
||||
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
||||
| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке |
|
||||
| Street View (Google) | Просмотр улиц (как в русских Google Картах) |
|
||||
| CONUS (NOAA region code) | CONUS, без перевода |
|
||||
| WHOT/BHOT, Ironbow (thermal modes, palette) | без перевода |
|
||||
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
||||
| Previous / Next (button text) | Назад / Далее |
|
||||
| anchor / data pack / bundle (scene) | якорь / пакет данных / архив |
|
||||
| basemap / box (Recent Imagery area) | подложка / область |
|
||||
| camera move | перелёт |
|
||||
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. |
|
||||
| compass points (N, NNE, …) | С, ССВ, … |
|
||||
| L / R (relative bearing) | Л / П |
|
||||
+318
-17
@@ -1,11 +1,16 @@
|
||||
// Keeps the locale catalogs in step with the code.
|
||||
//
|
||||
// npm run i18n check every catalog (also run by npm test)
|
||||
// npm run i18n check every catalog and find unmarked UI text
|
||||
// npm run i18n -- --prune also drop entries the code no longer uses
|
||||
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
|
||||
//
|
||||
// A translation identical to the English is reported too, unless it is
|
||||
// listed for that locale in src/i18n/same-as-english.json (codes, product
|
||||
// names, words spelled the same in both languages).
|
||||
//
|
||||
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||||
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
|
||||
// It also lists UI text that is not marked at all (see findUnmarked).
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
@@ -13,10 +18,11 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
|
||||
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
|
||||
|
||||
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
|
||||
const CALL = new RegExp(
|
||||
String.raw`\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
|
||||
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
@@ -93,26 +99,19 @@ export function extractMessages() {
|
||||
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
|
||||
};
|
||||
|
||||
const sources = trackedFiles(
|
||||
'src/*.js',
|
||||
'src/*.mjs',
|
||||
'src/**/*.js',
|
||||
'src/**/*.mjs',
|
||||
).filter(
|
||||
(file) =>
|
||||
!file.startsWith('src/i18n/') &&
|
||||
!/\.test\.mjs$|\/testSupport\//.test(file),
|
||||
);
|
||||
const sources = sourceFiles();
|
||||
for (const file of sources) {
|
||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
for (const match of source.matchAll(CALL)) {
|
||||
const line = source.slice(0, match.index).split('\n').length;
|
||||
const where = `${file}:${line}`;
|
||||
if (match[2]) {
|
||||
if (match[2][0] === '`' && match[2].includes('${')) continue;
|
||||
add(unquote(match[2]), where);
|
||||
if (match[1]) {
|
||||
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
|
||||
} else if (match[4]) {
|
||||
if (match[4][0] === '`' && match[4].includes('${')) continue;
|
||||
add(unquote(match[4]), where);
|
||||
} else {
|
||||
add(unquote(match[4]), where, { one: unquote(match[3]) });
|
||||
add(unquote(match[6]), where, { one: unquote(match[5]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +139,276 @@ export function extractMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
|
||||
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
||||
|
||||
const VOID_TAGS = new Set(
|
||||
'area base br col embed hr img input link meta source track wbr'.split(' '),
|
||||
);
|
||||
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
|
||||
|
||||
/**
|
||||
* User-visible markup text without data-i18n*. Skipped, with everything
|
||||
* inside: translate="no" (text that stays as is, as in HTML),
|
||||
* data-i18n-runtime (code sets this text through t()), and icons
|
||||
* (aria-hidden, Material Symbols).
|
||||
*/
|
||||
export function unmarkedMarkup(file, html) {
|
||||
const found = [];
|
||||
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
|
||||
c.replace(/[^\n]/g, ' '),
|
||||
);
|
||||
const TOKEN =
|
||||
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
|
||||
const stack = [];
|
||||
const skipping = () => stack.at(-1)?.skip === true;
|
||||
for (const match of source.matchAll(TOKEN)) {
|
||||
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
|
||||
if (text !== undefined) {
|
||||
const top = stack.at(-1);
|
||||
const value = normalize(decodeHtml(text));
|
||||
if (!skipping() && hasWords(value) && !top?.marked)
|
||||
found.push({
|
||||
where: `${file}:${lineAt(source, match.index)}`,
|
||||
text: value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const tag = rawTag.toLowerCase();
|
||||
if (closing) {
|
||||
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
|
||||
if (at >= 0) stack.length = at;
|
||||
continue;
|
||||
}
|
||||
const skip =
|
||||
skipping() ||
|
||||
SKIP_TAGS.has(tag) ||
|
||||
/\stranslate="no"/.test(attrs) ||
|
||||
/\sdata-i18n-runtime\b/.test(attrs) ||
|
||||
/\saria-hidden="true"/.test(attrs) ||
|
||||
/class="[^"]*material-symbols/.test(attrs);
|
||||
if (!skip) {
|
||||
for (const [, name, value] of attrs.matchAll(
|
||||
/\s(title|aria-label|placeholder)="([^"]*)"/g,
|
||||
)) {
|
||||
if (
|
||||
hasWords(value) &&
|
||||
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
|
||||
)
|
||||
found.push({
|
||||
where: `${file}:${lineAt(source, match.index)}`,
|
||||
text: `${name}="${decodeHtml(value)}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!VOID_TAGS.has(tag) && !selfClosing)
|
||||
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// Where a string becomes UI text. Each pattern ends where the value starts.
|
||||
const JS_SINKS = [
|
||||
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
|
||||
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
|
||||
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
|
||||
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
|
||||
/\b(?:showToast|toast|setHint|announce)\(\s*/g,
|
||||
/(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm,
|
||||
];
|
||||
const MARKERS = /\b(?:t|tn|msg)\(/y;
|
||||
|
||||
/** The expression starting at `start`, up to its end at bracket depth 0. */
|
||||
function expressionAt(source, start) {
|
||||
let depth = 0;
|
||||
let i = start;
|
||||
for (; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
i = skipString(source, i);
|
||||
continue;
|
||||
}
|
||||
if ('([{'.includes(c)) depth++;
|
||||
else if (')]}'.includes(c)) {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
} else if (depth === 0 && (c === ';' || c === ',')) break;
|
||||
else if (depth === 0 && c === '\n') {
|
||||
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
|
||||
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
|
||||
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
|
||||
break;
|
||||
}
|
||||
}
|
||||
return source.slice(start, i);
|
||||
}
|
||||
|
||||
function skipString(source, i) {
|
||||
const quote = source[i];
|
||||
for (let j = i + 1; j < source.length; j++) {
|
||||
if (source[j] === '\\') j++;
|
||||
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
|
||||
let depth = 1;
|
||||
for (j += 2; j < source.length && depth; j++) {
|
||||
if (source[j] === '{') depth++;
|
||||
else if (source[j] === '}') depth--;
|
||||
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
|
||||
}
|
||||
j--;
|
||||
} else if (source[j] === quote) return j;
|
||||
else if (quote !== '`' && source[j] === '\n') return j;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
/** String literals that sit in value position: not inside any call or index,
|
||||
* not compared, and not already passed to t/tn/msg. */
|
||||
function valueLiterals(expression) {
|
||||
const literals = [];
|
||||
let callDepth = 0;
|
||||
const stack = [];
|
||||
for (let i = 0; i < expression.length; i++) {
|
||||
const c = expression[i];
|
||||
if (c === '(' || c === '[') {
|
||||
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
|
||||
stack.push(isCall);
|
||||
if (isCall) callDepth++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')' || c === ']') {
|
||||
if (stack.pop()) callDepth--;
|
||||
continue;
|
||||
}
|
||||
MARKERS.lastIndex = i;
|
||||
if (MARKERS.test(expression)) {
|
||||
stack.push(true);
|
||||
callDepth++;
|
||||
i = MARKERS.lastIndex - 1;
|
||||
continue;
|
||||
}
|
||||
if (c !== "'" && c !== '"' && c !== '`') continue;
|
||||
const end = skipString(expression, i);
|
||||
const literal = expression.slice(i, end + 1);
|
||||
const before = expression.slice(0, i).trimEnd();
|
||||
const after = expression.slice(end + 1).trimStart();
|
||||
const compared =
|
||||
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
|
||||
/^(===|!==|==|!=|in\b)/.test(after);
|
||||
if (callDepth === 0 && !compared) literals.push(literal);
|
||||
i = end;
|
||||
}
|
||||
return literals;
|
||||
}
|
||||
|
||||
/** The text a literal shows: static template parts, HTML tags removed. */
|
||||
function visibleText(literal, html) {
|
||||
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
|
||||
if (html) {
|
||||
const attrs = [
|
||||
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
|
||||
].map((m) => m[1]);
|
||||
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
|
||||
}
|
||||
const text = normalize(body);
|
||||
if (!hasWords(text)) return null;
|
||||
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
|
||||
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
|
||||
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Blank out comments, keeping strings and line numbers intact. */
|
||||
function withoutComments(source) {
|
||||
let out = '';
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (c === "'" || c === '"' || c === '`') {
|
||||
const end = skipString(source, i);
|
||||
out += source.slice(i, end + 1);
|
||||
i = end;
|
||||
} else if (c === '/' && source[i + 1] === '/') {
|
||||
const end = source.indexOf('\n', i);
|
||||
const stop = end < 0 ? source.length : end;
|
||||
out += ' '.repeat(stop - i);
|
||||
i = stop - 1;
|
||||
} else if (c === '/' && source[i + 1] === '*') {
|
||||
const end = source.indexOf('*/', i + 2);
|
||||
const stop = end < 0 ? source.length : end + 2;
|
||||
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
|
||||
i = stop - 1;
|
||||
} else out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unmarkedScript(file, original) {
|
||||
const found = [];
|
||||
const lines = original.split('\n');
|
||||
const source = withoutComments(original);
|
||||
const ignored = (line) =>
|
||||
/i18n-ignore/.test(lines[line - 1] ?? '') ||
|
||||
/i18n-ignore/.test(lines[line - 2] ?? '');
|
||||
for (const sink of JS_SINKS) {
|
||||
sink.lastIndex = 0;
|
||||
for (const match of source.matchAll(sink)) {
|
||||
const start = match.index + match[0].length;
|
||||
const line = lineAt(source, start);
|
||||
if (ignored(line)) continue;
|
||||
const html = /HTML/.test(match[0]);
|
||||
for (const literal of valueLiterals(expressionAt(source, start))) {
|
||||
const text = visibleText(literal, html);
|
||||
if (text) found.push({ where: `${file}:${line}`, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI text that is not marked for translation: markup without data-i18n* and
|
||||
* string literals that reach the page without t(), tn() or msg().
|
||||
* Opt out with translate="no" in markup or an `// i18n-ignore` comment on
|
||||
* (or just above) the line in code.
|
||||
* @returns {{where: string, text: string}[]}
|
||||
*/
|
||||
export function findUnmarked() {
|
||||
const found = [];
|
||||
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
|
||||
found.push(
|
||||
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||
);
|
||||
for (const file of sourceFiles().filter(
|
||||
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
||||
))
|
||||
found.push(
|
||||
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||
);
|
||||
const seen = new Set();
|
||||
return found.filter(({ where, text }) => {
|
||||
const key = `${where}\0${text}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Text that never reaches the page as UI: tool results and descriptions for
|
||||
// AI clients (MCP and the voice model).
|
||||
const NOT_UI = [/^src\/tools\//];
|
||||
|
||||
function sourceFiles() {
|
||||
return trackedFiles(
|
||||
'src/*.js',
|
||||
'src/*.mjs',
|
||||
'src/**/*.js',
|
||||
'src/**/*.mjs',
|
||||
).filter(
|
||||
(file) =>
|
||||
!file.startsWith('src/i18n/') &&
|
||||
!/\.test\.mjs$|\/testSupport\//.test(file),
|
||||
);
|
||||
}
|
||||
|
||||
const placeholders = (text) =>
|
||||
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
|
||||
.map((m) => m[1])
|
||||
@@ -160,11 +429,26 @@ export function shippedLocales() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Messages a locale keeps exactly as in English, on purpose. */
|
||||
export function readSameAsEnglish(locale) {
|
||||
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
|
||||
return new Set(lists[locale] || []);
|
||||
}
|
||||
|
||||
/** The English a message shows: its text without the "context|" prefix. */
|
||||
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
|
||||
|
||||
/**
|
||||
* Compare one catalog with the extracted messages.
|
||||
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
|
||||
* @returns {{missing: string[], stale: string[], errors: string[]}}
|
||||
*/
|
||||
export function checkCatalog(locale, catalog, messages) {
|
||||
export function checkCatalog(
|
||||
locale,
|
||||
catalog,
|
||||
messages,
|
||||
sameAsEnglish = readSameAsEnglish(locale),
|
||||
) {
|
||||
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||
.pluralCategories;
|
||||
const missing = [];
|
||||
@@ -192,6 +476,14 @@ export function checkCatalog(locale, catalog, messages) {
|
||||
errors.push(`"${text}" must be a string`);
|
||||
} else if (placeholders(entry) !== placeholders(text)) {
|
||||
errors.push(`"${text}" placeholders differ: ${entry}`);
|
||||
} else if (
|
||||
entry === englishOf(text) &&
|
||||
/\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) &&
|
||||
!sameAsEnglish.has(text)
|
||||
) {
|
||||
errors.push(
|
||||
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
|
||||
@@ -249,6 +541,15 @@ function main(args) {
|
||||
console.log(`${locale}: ${messages.size} messages, complete`);
|
||||
}
|
||||
}
|
||||
const unmarked = findUnmarked();
|
||||
if (unmarked.length) {
|
||||
failed = true;
|
||||
console.log(
|
||||
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
|
||||
);
|
||||
for (const { where, text } of unmarked)
|
||||
console.log(` ${where} ${JSON.stringify(text)}`);
|
||||
}
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -455,7 +455,9 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -502,7 +504,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -556,7 +559,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -569,7 +573,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -712,7 +717,9 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/ui/keyGuidance.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -822,7 +829,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -880,7 +888,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1003,7 +1012,13 @@
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
"src/maps/imageryHost.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1130,7 +1145,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/layers/installations/classLabel.js"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -1161,7 +1177,8 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1356,7 +1373,11 @@
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/vectorTiles.js",
|
||||
"src/sources/phaseTiming.js",
|
||||
"src/layers/alpr/floors.js"
|
||||
"src/layers/alpr/floors.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -2341,7 +2362,11 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/ui/keyGuidance.js",
|
||||
"src/layers/installations/classLabel.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2880,7 +2905,10 @@
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/recentImagery/display.js",
|
||||
"src/layers/installations/classLabel.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3120,7 +3148,12 @@
|
||||
"modules": [
|
||||
"src/layers/directions/index.js",
|
||||
"src/layers/directions/source.js",
|
||||
"src/data/routeSteps.js"
|
||||
"src/data/routeSteps.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -3398,7 +3431,11 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/capability.js"
|
||||
"src/sources/capability.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
@@ -3480,7 +3517,11 @@
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js"
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/ru.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
|
||||
if (status && status !== 'CONNECTING') break;
|
||||
// The label is translated; the machine state is on the control.
|
||||
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
|
||||
if (status && status !== 'connecting') break;
|
||||
}
|
||||
let diag = null;
|
||||
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
||||
|
||||
@@ -1137,12 +1137,14 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'The Presidio (former Army base)',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1150,6 +1152,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1157,6 +1160,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina District, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'next to the Marina',
|
||||
},
|
||||
],
|
||||
@@ -1186,6 +1190,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'highlight',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Palace of Fine Arts',
|
||||
color: 'amber',
|
||||
},
|
||||
@@ -1200,6 +1205,7 @@ export function createAnnotationEngine({
|
||||
type: 'arrow',
|
||||
target: 'Palace of Fine Arts, San Francisco',
|
||||
toTarget: 'Marina Green, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'next to the Marina',
|
||||
color: 'cyan',
|
||||
},
|
||||
@@ -1223,6 +1229,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'area',
|
||||
target: 'Presidio of San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'The Presidio — a former Army base',
|
||||
color: 'green',
|
||||
footprint: true,
|
||||
@@ -1237,6 +1244,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'pin',
|
||||
target: 'Letterman Digital Arts Center, San Francisco',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'ILM / Lucasfilm',
|
||||
color: 'red',
|
||||
},
|
||||
@@ -1250,6 +1258,7 @@ export function createAnnotationEngine({
|
||||
{
|
||||
type: 'route',
|
||||
color: 'amber',
|
||||
// i18n-ignore: dev demo standing in for voice-agent labels, which are not translated
|
||||
label: 'Crissy Field shoreline',
|
||||
points: [
|
||||
{ target: 'Palace of Fine Arts, San Francisco' },
|
||||
|
||||
@@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// a limit rather than the harmless tail of a double-click.
|
||||
if (reason === 'full')
|
||||
setHint(
|
||||
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
|
||||
t(
|
||||
'That shape already has {count} points — finish it or press Backspace.',
|
||||
{ count: MAX_VERTICES },
|
||||
),
|
||||
);
|
||||
else if (reason === 'invalid')
|
||||
setHint(t('That point is off the globe — click on the world.'));
|
||||
@@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (destroyed || attempt !== generation) return null;
|
||||
setHint(`Could not place the shape: ${error?.message || error}`);
|
||||
setHint(
|
||||
t('Could not place the shape: {error}', {
|
||||
error: error?.message || error,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) {
|
||||
// Somebody else is using the pointer — possibly an older instance of
|
||||
// this same tool that has not finished tearing down. Say so instead of
|
||||
// half-starting.
|
||||
setHint(`${pointerOwner()} is using the pointer — close it first.`);
|
||||
setHint(
|
||||
t('{owner} is using the pointer — close it first.', {
|
||||
owner: pointerOwner(),
|
||||
}),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
getActiveTrackedReadoutId,
|
||||
TRACKED_OVERLAY_SOURCE_ID,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** A label whose detailed outline could not be resolved. */
|
||||
function outlineUnavailableLabel(label) {
|
||||
return t('{label} · Detailed outline unavailable', { label: label || '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic).
|
||||
@@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.poly);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.path);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer(
|
||||
});
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer(
|
||||
group.appendChild(parts.leader);
|
||||
parts.label = makeCallout(
|
||||
anno.outlineUnavailable
|
||||
? `${anno.label || ''} · Detailed outline unavailable`
|
||||
? outlineUnavailableLabel(anno.label)
|
||||
: anno.label,
|
||||
c,
|
||||
);
|
||||
@@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer(
|
||||
}
|
||||
rec.anno = anno;
|
||||
if (anno.outlineUnavailable && rec.parts.label) {
|
||||
rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`;
|
||||
rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label);
|
||||
sizeCallout(rec.parts.label);
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +541,7 @@ export function panelRuntime(config) {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'status';
|
||||
notice.textContent =
|
||||
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
|
||||
"This panel lost its 3D graphics, likely because other God's Eye " +
|
||||
"View panels in this conversation hold them. Use Open in God's " +
|
||||
'Eye View above, or show it in a new conversation.';
|
||||
@@ -616,6 +617,7 @@ export function panelRuntime(config) {
|
||||
if (context?.displayMode) displayMode = context.displayMode;
|
||||
if (context?.availableDisplayModes)
|
||||
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
|
||||
// i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale
|
||||
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
|
||||
};
|
||||
expand.addEventListener('click', () => {
|
||||
@@ -640,6 +642,7 @@ export function panelRuntime(config) {
|
||||
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
|
||||
appInfo: {
|
||||
name: 'gods-eye-view',
|
||||
// i18n-ignore: product name sent to the MCP host
|
||||
title: "God's Eye View",
|
||||
version: '1.0.0',
|
||||
},
|
||||
|
||||
+647
-183
File diff suppressed because it is too large.
Load diff
+26
-5
@@ -83,11 +83,32 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
||||
}
|
||||
|
||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||
/**
|
||||
* The English Context scope line. The cockpit passes its own formatter so the
|
||||
* page shows it in the active language; this module stays i18n-free.
|
||||
*/
|
||||
const ENGLISH_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
`${label} · ${radiusKm ?? '—'} KM AIR/SEA WINDOW`,
|
||||
installations: ({ scope, coverage }) =>
|
||||
`${scope} · INSTALLATIONS ${coverage}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Format the cockpit Context scope without overstating installation coverage.
|
||||
* @param {string|number} subjectLabel
|
||||
* @param {number} radiusM
|
||||
* @param {string|null} [installationCoverage]
|
||||
* @param {{window: Function, installations: Function}} [format] Builds the
|
||||
* line from `{ label, radiusKm }` (radiusKm is null when unknown) and
|
||||
* `{ scope, coverage }`.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCockpitContextScope(
|
||||
subjectLabel,
|
||||
radiusM,
|
||||
installationCoverage = null,
|
||||
format = ENGLISH_CONTEXT_SCOPE,
|
||||
) {
|
||||
const normalizedLabel =
|
||||
typeof subjectLabel === 'string'
|
||||
@@ -97,12 +118,12 @@ export function formatCockpitContextScope(
|
||||
: '—';
|
||||
const radiusKm =
|
||||
Number.isFinite(radiusM) && radiusM >= 0
|
||||
? String(Math.round(radiusM / 1000))
|
||||
: '—';
|
||||
? Math.round(radiusM / 1000)
|
||||
: null;
|
||||
const coverage =
|
||||
typeof installationCoverage === 'string' ? installationCoverage.trim() : '';
|
||||
const base = `${normalizedLabel} · ${radiusKm} KM AIR/SEA WINDOW`;
|
||||
return coverage ? `${base} · INSTALLATIONS ${coverage}` : base;
|
||||
const base = format.window({ label: normalizedLabel, radiusKm });
|
||||
return coverage ? format.installations({ scope: base, coverage }) : base;
|
||||
}
|
||||
|
||||
/** Return seven 30-degree compass divisions centered on a heading. */
|
||||
|
||||
+206
-53
@@ -4,7 +4,9 @@ import { afterEach } from 'node:test';
|
||||
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
const previousDocument = globalThis.document;
|
||||
afterEach(() => { globalThis.document = previousDocument; });
|
||||
afterEach(() => {
|
||||
globalThis.document = previousDocument;
|
||||
});
|
||||
|
||||
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
|
||||
// object reference alone is insufficient if reconciliation disconnects it.
|
||||
@@ -22,23 +24,60 @@ function fixture() {
|
||||
this.className = '';
|
||||
this._text = '';
|
||||
this.classList = {
|
||||
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
|
||||
add: (...names) => {
|
||||
this.className = [
|
||||
...new Set([
|
||||
...this.className.split(/\s+/).filter(Boolean),
|
||||
...names,
|
||||
]),
|
||||
].join(' ');
|
||||
},
|
||||
};
|
||||
}
|
||||
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
|
||||
get firstElementChild() { return this.children[0] || null; }
|
||||
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
|
||||
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
|
||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
||||
get isConnected() {
|
||||
return this === document.body || Boolean(this.parentNode?.isConnected);
|
||||
}
|
||||
get firstElementChild() {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
return (
|
||||
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
get textContent() {
|
||||
return (
|
||||
this._text + this.children.map((node) => node.textContent).join('')
|
||||
);
|
||||
}
|
||||
set textContent(value) {
|
||||
this.replaceChildren();
|
||||
this._text = String(value);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
contains(node) {
|
||||
return (
|
||||
this === node || this.children.some((child) => child.contains(node))
|
||||
);
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) this.insertBefore(node, null);
|
||||
}
|
||||
insertBefore(node, anchor) {
|
||||
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
|
||||
assert.ok(
|
||||
anchor === null || anchor.parentNode === this,
|
||||
'insert anchor belongs to its parent',
|
||||
);
|
||||
if (node === anchor) return node;
|
||||
node.remove();
|
||||
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
const index =
|
||||
anchor === null ? this.children.length : this.children.indexOf(anchor);
|
||||
this.children.splice(index, 0, node);
|
||||
node.parentNode = this;
|
||||
return node;
|
||||
@@ -49,7 +88,10 @@ function fixture() {
|
||||
document.activeElement = document.body;
|
||||
focusLosses += 1;
|
||||
}
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode.children.splice(
|
||||
this.parentNode.children.indexOf(this),
|
||||
1,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
@@ -59,20 +101,32 @@ function fixture() {
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === 'button[data-signal-layer][data-signal-id]') {
|
||||
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
|
||||
return (
|
||||
this.tagName === 'BUTTON' &&
|
||||
this.dataset.signalLayer != null &&
|
||||
this.dataset.signalId != null
|
||||
);
|
||||
}
|
||||
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
return selector.startsWith('.')
|
||||
? this.className.split(/\s+/).includes(selector.slice(1))
|
||||
: this.tagName === selector.toUpperCase();
|
||||
}
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
|
||||
for (let node = this; node; node = node.parentNode)
|
||||
if (node.matches(selector)) return node;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
|
||||
return this.children.flatMap((child) => [
|
||||
...(child.matches(selector) ? [child] : []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
focus() {
|
||||
assert.ok(this.isConnected, 'a focus destination must still be connected');
|
||||
assert.ok(
|
||||
this.isConnected,
|
||||
'a focus destination must still be connected',
|
||||
);
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
@@ -85,7 +139,11 @@ function fixture() {
|
||||
controller.services = { formatAwarenessLabel };
|
||||
controller.signalList = new Node('ol');
|
||||
controller.signalToggle = new Node('button');
|
||||
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
|
||||
controller.briefTabs = [
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
new Node('button'),
|
||||
];
|
||||
controller.briefPageIndex = 0;
|
||||
controller.signalItems = [];
|
||||
controller.signalSignatures = new Map();
|
||||
@@ -93,64 +151,131 @@ function fixture() {
|
||||
const display = new Node('button');
|
||||
const radio = new Node('button');
|
||||
const outside = new Node('button');
|
||||
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
|
||||
document.body.append(
|
||||
controller.signalToggle,
|
||||
controller.signalList,
|
||||
...controller.briefTabs,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
);
|
||||
const selected = [];
|
||||
controller.services.militaryAwarenessLayer = { focusTarget: (...args) => selected.push(args) };
|
||||
const click = event => controller.handleSignalClick(event);
|
||||
controller.services.militaryAwarenessLayer = {
|
||||
focusTarget: (...args) => selected.push(args),
|
||||
};
|
||||
const click = (event) => controller.handleSignalClick(event);
|
||||
const buttons = () => controller.signalList.querySelectorAll('button');
|
||||
const tab = () => {
|
||||
const nodes = document.body.querySelectorAll('button');
|
||||
const index = nodes.indexOf(document.activeElement);
|
||||
nodes[(index + 1) % nodes.length].focus();
|
||||
};
|
||||
return { controller, document, buttons, display, radio, outside, selected, click, tab,
|
||||
losses: () => focusLosses, focusCalls: () => focusCalls };
|
||||
return {
|
||||
controller,
|
||||
document,
|
||||
buttons,
|
||||
display,
|
||||
radio,
|
||||
outside,
|
||||
selected,
|
||||
click,
|
||||
tab,
|
||||
losses: () => focusLosses,
|
||||
focusCalls: () => focusCalls,
|
||||
};
|
||||
}
|
||||
|
||||
const item = (id, overrides = {}) => ({
|
||||
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
|
||||
key: `flight:flights:${id}`,
|
||||
tone: 'nearby',
|
||||
title: `Flight ${id}`,
|
||||
detail: 'COMMERCIAL FLIGHT · 2 KM',
|
||||
target: { layerId: 'flights', id },
|
||||
timestamp: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
|
||||
const render = (f, items) => {
|
||||
f.controller.signalItems = items;
|
||||
f.controller.renderCockpitSignals();
|
||||
};
|
||||
const snapshot = (distanceM = 2000) => ({
|
||||
evaluatedAt: 1_700_000_000_000,
|
||||
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
|
||||
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 1,
|
||||
nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
|
||||
const f = fixture();
|
||||
f.controller.updateCockpitSignals(snapshot(), 0);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
for (let tick = 0; tick < 8; tick += 1)
|
||||
f.controller.updateCockpitSignals(snapshot(2500), 0);
|
||||
assert.equal(f.buttons()[1], target);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
|
||||
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
|
||||
assert.equal(
|
||||
f.focusCalls(),
|
||||
calls,
|
||||
'refresh must preserve focus without repeatedly restoring it',
|
||||
);
|
||||
assert.match(f.controller.signalList.textContent, /2\.5\u00a0KM/);
|
||||
});
|
||||
|
||||
test('signal content and accessible name update without replacing the active target', () => {
|
||||
const f = fixture(); render(f, [item('a')]);
|
||||
const target = f.buttons()[0]; target.focus();
|
||||
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
|
||||
const f = fixture();
|
||||
render(f, [item('a')]);
|
||||
const target = f.buttons()[0];
|
||||
target.focus();
|
||||
render(f, [
|
||||
item('a', {
|
||||
title: 'RENAMED',
|
||||
tone: 'track',
|
||||
detail: 'CURRENT',
|
||||
timestamp: 1_700_000_001_000,
|
||||
}),
|
||||
]);
|
||||
assert.equal(f.buttons()[0], target);
|
||||
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
|
||||
assert.match(target.textContent, /RENAMED/);
|
||||
assert.match(f.controller.signalList.textContent, /CURRENT/);
|
||||
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
|
||||
assert.equal(
|
||||
f.controller.signalList.children[0].className,
|
||||
'track actionable',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
});
|
||||
|
||||
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
|
||||
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
const f = fixture();
|
||||
render(
|
||||
f,
|
||||
['a', 'b', 'c', 'd'].map((id) => item(id)),
|
||||
);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
const calls = f.focusCalls();
|
||||
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
|
||||
render(f, order.map((id) => item(id)));
|
||||
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
|
||||
for (const order of [
|
||||
['d', 'c', 'b', 'a'],
|
||||
['b', 'c', 'd', 'a'],
|
||||
['a', 'd', 'c', 'b'],
|
||||
]) {
|
||||
render(
|
||||
f,
|
||||
order.map((id) => item(id)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.buttons().map((button) => button.dataset.signalId),
|
||||
order,
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
}
|
||||
@@ -158,37 +283,65 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
|
||||
});
|
||||
|
||||
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1]; target.focus();
|
||||
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
const target = f.buttons()[1];
|
||||
target.focus();
|
||||
f.controller.pushCockpitSignal(
|
||||
'status',
|
||||
'warning',
|
||||
'INPUT UNKNOWN',
|
||||
'SOURCE UNAVAILABLE',
|
||||
);
|
||||
assert.equal(f.document.activeElement, target);
|
||||
assert.equal(f.losses(), 0);
|
||||
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
|
||||
const event = {
|
||||
target: target.children[0],
|
||||
prevented: false,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
};
|
||||
f.click(event);
|
||||
assert.equal(event.prevented, true);
|
||||
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
|
||||
});
|
||||
|
||||
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
|
||||
const f = fixture(); render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1]; removed.focus();
|
||||
const f = fixture();
|
||||
render(f, [item('a'), item('b')]);
|
||||
const removed = f.buttons()[1];
|
||||
removed.focus();
|
||||
const calls = f.focusCalls();
|
||||
render(f, [item('a')]);
|
||||
assert.equal(removed.isConnected, false);
|
||||
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
|
||||
assert.equal(f.focusCalls(), calls + 1);
|
||||
f.tab(); f.tab(); f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.display);
|
||||
render(f, [item('a', { detail: 'UPDATED' })]);
|
||||
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
|
||||
assert.equal(
|
||||
f.document.activeElement,
|
||||
f.display,
|
||||
'later refresh must not pull focus back',
|
||||
);
|
||||
f.tab();
|
||||
assert.equal(f.document.activeElement, f.radio);
|
||||
});
|
||||
|
||||
test('a changed target cannot reuse a focused action for another flight', () => {
|
||||
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0]; removed.focus();
|
||||
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
|
||||
const f = fixture();
|
||||
render(f, [item('a', { key: 'shared-status-key' })]);
|
||||
const removed = f.buttons()[0];
|
||||
removed.focus();
|
||||
render(f, [
|
||||
item('b', {
|
||||
key: 'shared-status-key',
|
||||
target: { layerId: 'military', id: 'b' },
|
||||
}),
|
||||
]);
|
||||
const target = f.buttons()[0];
|
||||
assert.notEqual(target, removed);
|
||||
assert.equal(removed.isConnected, false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
'youtube.com',
|
||||
@@ -39,6 +40,57 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
||||
facebook: Object.freeze(['https://www.facebook.com']),
|
||||
});
|
||||
|
||||
// Authored English in public/events/bhote-koshi-2026/event.json. The pack is
|
||||
// data and stays English; these known strings translate at display. Anything
|
||||
// else (a newer pack, a credit, a source's own title) shows as authored. A
|
||||
// string that is not known whole translates by its " · " parts.
|
||||
const EVENT_PACK_TEXT = new Set([
|
||||
// Field reports: our labels (credits after " · " stay) and their statuses.
|
||||
msg('Rasuwagadhi witness video'),
|
||||
msg('Bhote Koshi valley footage'),
|
||||
msg('Flood footage'),
|
||||
msg('Compilation and casualty claims'),
|
||||
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
|
||||
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
|
||||
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
|
||||
msg('SOURCE GEOCONFIRMED'),
|
||||
// Reconstruction caveat.
|
||||
msg(
|
||||
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
|
||||
),
|
||||
// Evidence titles that are more than a place name.
|
||||
msg('Immediate collapse viewpoint'),
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Debris-dammed lake'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Gyirong border gate'),
|
||||
msg('Border gate'),
|
||||
msg('Timure evidence cluster'),
|
||||
msg('Mailung / Upper Trishuli bridge'),
|
||||
msg('Dandagaun viewpoint restaurant'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur - Trishuli bridge'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat / Taadi Khola Bridge'),
|
||||
msg('Devighat bridge'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display text for an authored event-pack string.
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bhoteEventText(value) {
|
||||
const text = value == null ? '' : String(value);
|
||||
if (EVENT_PACK_TEXT.has(text)) return t(text);
|
||||
if (!text.includes(' · ')) return text;
|
||||
return text
|
||||
.split(' · ')
|
||||
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
let _xWidgetsPromise = null;
|
||||
let _facebookSdkPromise = null;
|
||||
let _facebookEmbedSequence = 0;
|
||||
@@ -240,7 +292,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
prior?.();
|
||||
} finally {
|
||||
if (facebookSdkReady(globalRef.FB)) finish();
|
||||
else finish(new Error('Facebook player API did not initialize.'));
|
||||
else finish(new Error(msg('Facebook player API did not initialize.')));
|
||||
}
|
||||
};
|
||||
globalRef.fbAsyncInit = ready;
|
||||
@@ -256,12 +308,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('Facebook player API could not load.')),
|
||||
() => finish(new Error(msg('Facebook player API could not load.'))),
|
||||
{ once: true },
|
||||
);
|
||||
timer =
|
||||
globalRef.setTimeout?.(
|
||||
() => finish(new Error('Facebook player API timed out.')),
|
||||
() => finish(new Error(msg('Facebook player API timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
) ?? null;
|
||||
}).catch((error) => {
|
||||
@@ -398,7 +450,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
};
|
||||
const ready = () => {
|
||||
if (xWidgetsReady(globalRef.twttr)) finish();
|
||||
else finish(new Error('X embed API did not initialize.'));
|
||||
else finish(new Error(msg('X embed API did not initialize.')));
|
||||
};
|
||||
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
||||
if (!script) {
|
||||
@@ -411,7 +463,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
}
|
||||
script.addEventListener?.(
|
||||
'error',
|
||||
() => finish(new Error('X embed could not load.')),
|
||||
() => finish(new Error(msg('X embed could not load.'))),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
@@ -440,7 +492,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
},
|
||||
);
|
||||
timer = setTimeout(
|
||||
() => finish(new Error('X embed timed out.')),
|
||||
() => finish(new Error(msg('X embed timed out.'))),
|
||||
EMBED_TIMEOUT_MS,
|
||||
);
|
||||
}).catch((error) => {
|
||||
@@ -450,9 +502,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
|
||||
return _xWidgetsPromise;
|
||||
}
|
||||
|
||||
// Outcomes a trimmed YouTube clip reports to the card status.
|
||||
const YOUTUBE_PHASE_LABELS = Object.freeze({
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
timeout: msg('TIMEOUT'),
|
||||
blocked: msg('BLOCKED'),
|
||||
});
|
||||
|
||||
function providerLabel(provider) {
|
||||
return (
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
|
||||
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
|
||||
t('SOURCE')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,7 +797,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
globalRef.setTimeout?.(() => {
|
||||
if (!session.active || session.ready) return;
|
||||
session.error = new Error(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
msg(
|
||||
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
||||
),
|
||||
);
|
||||
session.onError?.(session.error);
|
||||
}, EMBED_TIMEOUT_MS) ?? null;
|
||||
@@ -857,7 +919,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
startAtSec,
|
||||
endAtSec,
|
||||
});
|
||||
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
||||
iframe.title = t('Preloading {provider} media', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
iframe.tabIndex = -1;
|
||||
iframe.setAttribute('aria-hidden', 'true');
|
||||
iframe.loading = 'eager';
|
||||
@@ -868,7 +932,7 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
}
|
||||
|
||||
async function mountX(current, token) {
|
||||
current.status.textContent = 'LOADING X POST…';
|
||||
current.status.textContent = t('LOADING X POST…');
|
||||
try {
|
||||
const api = await xLoader(documentRef, globalRef);
|
||||
if (token !== generation || record !== current) return;
|
||||
@@ -885,13 +949,17 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
);
|
||||
if (token !== generation || record !== current) return;
|
||||
if (!widget)
|
||||
throw new Error('The X post is unavailable or cannot be embedded.');
|
||||
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
||||
throw new Error(
|
||||
msg('The X post is unavailable or cannot be embedded.'),
|
||||
);
|
||||
current.status.textContent = t('X POST · PROVIDER CONTROLS');
|
||||
position();
|
||||
} catch (error) {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('X embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
}
|
||||
}
|
||||
@@ -904,19 +972,23 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
session.shouldPlay = autoplay === true;
|
||||
session.onReady = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
||||
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
|
||||
current.card.classList.remove('is-unavailable');
|
||||
position();
|
||||
};
|
||||
session.onError = (error) => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.card.classList.add('is-unavailable');
|
||||
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
||||
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
|
||||
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
|
||||
});
|
||||
position();
|
||||
};
|
||||
session.onPlaybackBlocked = () => {
|
||||
if (token !== generation || record !== current) return;
|
||||
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
||||
current.status.textContent = t(
|
||||
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
|
||||
);
|
||||
};
|
||||
if (session.ready) session.onReady();
|
||||
else if (session.error) session.onError(session.error);
|
||||
@@ -968,7 +1040,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
card.dataset.provider = source.provider;
|
||||
card.setAttribute(
|
||||
'aria-label',
|
||||
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
|
||||
t('Embedded {provider} media for {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
}),
|
||||
);
|
||||
const leader = documentRef.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
@@ -982,17 +1059,19 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
player.className = 'bhote-embedded-callout-player';
|
||||
const footer = documentRef.createElement('footer');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = String(
|
||||
observation?.shortTitle || observation?.title || 'Witness source',
|
||||
title.textContent = bhoteEventText(
|
||||
observation?.shortTitle || observation?.title || t('Witness source'),
|
||||
).toUpperCase();
|
||||
const status = documentRef.createElement('small');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
||||
status.textContent = t('{provider} · EMBEDDED SOURCE', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
const link = documentRef.createElement('a');
|
||||
link.href = source.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = 'OPEN ORIGINAL ↗';
|
||||
link.textContent = t('OPEN ORIGINAL ↗');
|
||||
footer.append(title, status, link);
|
||||
const body = documentRef.createElement('div');
|
||||
body.className = 'bhote-embedded-callout-body';
|
||||
@@ -1032,7 +1111,12 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
url.searchParams.set('origin', globalRef.location.origin);
|
||||
iframe.src = url.href;
|
||||
}
|
||||
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
||||
iframe.title = t('{provider} media: {source}', {
|
||||
provider: providerLabel(source.provider),
|
||||
source: observation?.title
|
||||
? bhoteEventText(observation.title)
|
||||
: t('witness source'),
|
||||
});
|
||||
iframe.allow =
|
||||
'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
iframe.allowFullscreen = true;
|
||||
@@ -1049,7 +1133,9 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
record.youtubeSession.getState().phase,
|
||||
)
|
||||
) {
|
||||
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
||||
status.textContent = t('{provider} · PROVIDER CONTROLS', {
|
||||
provider: providerLabel(source.provider),
|
||||
});
|
||||
}
|
||||
position();
|
||||
},
|
||||
@@ -1067,7 +1153,11 @@ export function createBhoteKoshiEmbeddedMedia({
|
||||
isCurrent: () => token === generation && record === current,
|
||||
onStatus: (phase) => {
|
||||
if (phase !== 'completed')
|
||||
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
||||
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
|
||||
state: YOUTUBE_PHASE_LABELS[phase]
|
||||
? t(YOUTUBE_PHASE_LABELS[phase])
|
||||
: phase.toUpperCase(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+196
-71
@@ -19,11 +19,13 @@ import {
|
||||
SOURCE_PATH_BEAT_IDS,
|
||||
} from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
bhoteEventText,
|
||||
createBhoteKoshiEmbeddedMedia,
|
||||
resolveEmbeddedMediaSource,
|
||||
} from './bhoteKoshiEmbeddedMedia.js';
|
||||
import { acquireImageryComparison } from '../maps/imageryComparison.js';
|
||||
import { createImagerySplit } from '../ui/imagerySplit.js';
|
||||
import { formatDate, t, tn, msg } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
|
||||
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
|
||||
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
|
||||
return Math.max(0, Math.min(beats.length - 1, current + delta));
|
||||
}
|
||||
|
||||
// Evidence phases from the event pack, shown as the beat meta prefix.
|
||||
const EVIDENCE_PHASE_LABELS = Object.freeze({
|
||||
initiation: msg('INITIATION'),
|
||||
damming: msg('DAMMING'),
|
||||
'secondary failure': msg('SECONDARY FAILURE'),
|
||||
'border arrival': msg('BORDER ARRIVAL'),
|
||||
propagation: msg('PROPAGATION'),
|
||||
'downstream passage': msg('DOWNSTREAM PASSAGE'),
|
||||
consequence: msg('CONSEQUENCE'),
|
||||
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
|
||||
});
|
||||
|
||||
/** Display label for an evidence phase; unknown phases show as authored. */
|
||||
function evidencePhaseLabel(phase) {
|
||||
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
|
||||
if (label) return t(label);
|
||||
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
|
||||
}
|
||||
|
||||
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
|
||||
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
|
||||
const IMAGERY_LABEL_NAMES = Object.freeze([
|
||||
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
|
||||
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
|
||||
]);
|
||||
|
||||
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
|
||||
function imageryDateText(observedAt) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
|
||||
if (!match) return '';
|
||||
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
|
||||
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized name and date of an imagery endpoint for display.
|
||||
* @param {{label?: string, observedAt?: string}} entry
|
||||
* @returns {{name: string, date: string}}
|
||||
*/
|
||||
export function imageryEndpointLabel(entry) {
|
||||
const label = String(entry?.label || '');
|
||||
const split = label.lastIndexOf(' · ');
|
||||
const authoredName = split >= 0 ? label.slice(0, split) : label;
|
||||
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
|
||||
let name = authoredName;
|
||||
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
|
||||
const match = pattern.exec(authoredName);
|
||||
if (match) name = t(message, { year: match[1] });
|
||||
}
|
||||
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
|
||||
}
|
||||
|
||||
function imageryEndpointText(entry) {
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
return [name, date].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** Name, then the date kept on one line so the year never wraps alone. */
|
||||
function renderImageryEndpoint(element, entry) {
|
||||
if (!element) return;
|
||||
const { name, date } = imageryEndpointLabel(entry);
|
||||
element.textContent = date ? `${name} · ` : name;
|
||||
if (!date) return;
|
||||
const dateElement = document.createElement('span');
|
||||
dateElement.className = 'bhote-event-date';
|
||||
dateElement.textContent = date;
|
||||
element.appendChild(dateElement);
|
||||
}
|
||||
|
||||
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
|
||||
export function evidenceCorroborationLabel(observation) {
|
||||
const count = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
|
||||
return count > 1
|
||||
? tn(
|
||||
count,
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
|
||||
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Interpolate headings across north using the shortest angular path. */
|
||||
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
|
||||
}
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
|
||||
ctx.fillText('NO PREVIEW', 20, 30);
|
||||
ctx.fillText(t('NO PREVIEW'), 20, 30);
|
||||
}
|
||||
|
||||
function ellipsizeEvidenceText(value, limit) {
|
||||
@@ -704,7 +783,7 @@ export function updateEvidenceFrame(
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
|
||||
ctx.fillText(
|
||||
ellipsizeEvidenceText(
|
||||
observation.shortTitle || observation.title,
|
||||
bhoteEventText(observation.shortTitle || observation.title),
|
||||
layout.titleChars,
|
||||
).toUpperCase(),
|
||||
16,
|
||||
@@ -713,10 +792,12 @@ export function updateEvidenceFrame(
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
|
||||
const publisher = String(
|
||||
observation.media?.publisher || 'SOURCE LINK',
|
||||
observation.media?.publisher || t('SOURCE LINK'),
|
||||
).toUpperCase();
|
||||
ctx.fillText(
|
||||
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
|
||||
t('{publisher} / OPEN', {
|
||||
publisher: ellipsizeEvidenceText(publisher, 24),
|
||||
}),
|
||||
16,
|
||||
infoY + 53,
|
||||
);
|
||||
@@ -813,28 +894,40 @@ function collectPanelReferences(panel) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape translated text for the panel markup template. */
|
||||
function escapeMarkup(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function createPanel(event, handlers) {
|
||||
const panel = document.createElement('aside');
|
||||
panel.id = 'bhote-koshi-event-panel';
|
||||
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
|
||||
panel.setAttribute(
|
||||
'aria-label',
|
||||
t('Bhote Koshi flood reconstruction controls'),
|
||||
);
|
||||
panel.innerHTML = `
|
||||
<div class="bhote-event-header">
|
||||
<div>
|
||||
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
|
||||
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
|
||||
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
|
||||
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
|
||||
</div>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
|
||||
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
|
||||
</div>
|
||||
<div class="bhote-event-status-row">
|
||||
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
|
||||
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
|
||||
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
|
||||
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
|
||||
</div>
|
||||
<section class="bhote-event-section" data-role="imagery-comparison">
|
||||
<div class="bhote-event-section-head">
|
||||
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
|
||||
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
|
||||
<span data-role="split-readout">50 / 50</span>
|
||||
</div>
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
|
||||
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
|
||||
<div class="bhote-event-dates">
|
||||
<span data-role="before-date"></span>
|
||||
<span data-role="after-date"></span>
|
||||
@@ -842,44 +935,44 @@ function createPanel(event, handlers) {
|
||||
</section>
|
||||
<section class="bhote-event-section">
|
||||
<div class="bhote-event-section-head">
|
||||
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
|
||||
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
|
||||
<span data-role="time"></span>
|
||||
</div>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
|
||||
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
|
||||
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
|
||||
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
|
||||
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
|
||||
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
|
||||
<div class="bhote-event-story-current">
|
||||
<span data-role="beat-index">01 / 06</span>
|
||||
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
|
||||
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
|
||||
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
|
||||
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
|
||||
</div>
|
||||
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
|
||||
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
|
||||
</div>
|
||||
<div class="bhote-event-actions">
|
||||
<button type="button" data-action="play">▶ PLAY</button>
|
||||
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
|
||||
<button type="button" data-action="story-replay">↺ FULL STORY</button>
|
||||
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
|
||||
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
|
||||
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
|
||||
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
|
||||
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
|
||||
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
|
||||
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
|
||||
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
|
||||
</div>
|
||||
</section>
|
||||
<details class="bhote-event-evidence">
|
||||
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
|
||||
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
|
||||
<div class="bhote-event-report-list"></div>
|
||||
</details>
|
||||
<div class="bhote-event-credit">
|
||||
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
|
||||
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
|
||||
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
|
||||
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
|
||||
</div>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
|
||||
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
|
||||
`;
|
||||
|
||||
const refs = collectPanelReferences(panel);
|
||||
refs.beforeDate.textContent = event.imagery.before.label;
|
||||
refs.afterDate.textContent = event.imagery.after.label;
|
||||
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
|
||||
renderImageryEndpoint(refs.afterDate, event.imagery.after);
|
||||
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
|
||||
refs.caveat.textContent = event.reconstruction.caveat;
|
||||
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
|
||||
|
||||
for (const report of event.fieldReports) {
|
||||
const button = document.createElement('button');
|
||||
@@ -887,8 +980,8 @@ function createPanel(event, handlers) {
|
||||
const status = document.createElement('small');
|
||||
button.type = 'button';
|
||||
button.className = 'bhote-event-report';
|
||||
label.textContent = report.label;
|
||||
status.textContent = report.status;
|
||||
label.textContent = bhoteEventText(report.label);
|
||||
status.textContent = bhoteEventText(report.status);
|
||||
button.append(label, status);
|
||||
button.addEventListener('click', () => openExternal(report.url));
|
||||
refs.reportList.appendChild(button);
|
||||
@@ -1205,7 +1298,7 @@ export function createBhoteKoshiEventLayer({
|
||||
_embeddedMedia?.show?.({
|
||||
observation: {
|
||||
title: witness.title,
|
||||
shortTitle: 'Rasuwagadhi witness',
|
||||
shortTitle: t('Rasuwagadhi witness'),
|
||||
media: { sourceUrl: witness.sourceUrl },
|
||||
},
|
||||
anchor,
|
||||
@@ -2320,7 +2413,10 @@ export function createBhoteKoshiEventLayer({
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 60,
|
||||
interactive: true,
|
||||
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
|
||||
accessibilityLabel: t(
|
||||
'Open original source for {source}; capture time unverified',
|
||||
{ source: bhoteEventText(observation.title) },
|
||||
),
|
||||
activate: () => openExternal(observation.media.sourceUrl),
|
||||
minDistance: 0,
|
||||
maxDistance: 220000,
|
||||
@@ -2353,7 +2449,7 @@ export function createBhoteKoshiEventLayer({
|
||||
position: evidencePositionGetter(index, true),
|
||||
cullPosition: anchor,
|
||||
variant: 'label',
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
|
||||
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
|
||||
details: [],
|
||||
accent: WORLD_OVERLAY_STYLE.accent,
|
||||
priority: 80_000 - index,
|
||||
@@ -2559,11 +2655,14 @@ export function createBhoteKoshiEventLayer({
|
||||
id: 'bhote-koshi-split-line',
|
||||
handleClass: 'bhote-koshi-split-handle',
|
||||
cssProperty: '--bhote-koshi-split',
|
||||
beforeTitle: event.imagery.before.label,
|
||||
afterTitle: event.imagery.after.label,
|
||||
ariaLabel: 'Historical reference and post-event image divider',
|
||||
beforeTitle: imageryEndpointText(event.imagery.before),
|
||||
afterTitle: imageryEndpointText(event.imagery.after),
|
||||
ariaLabel: t('Historical reference and post-event image divider'),
|
||||
formatValueText: (beforePercent, afterPercent) =>
|
||||
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
|
||||
t(
|
||||
'A historical reference {before} percent, B post-event {after} percent',
|
||||
{ before: beforePercent, after: afterPercent },
|
||||
),
|
||||
onChange: setSplit,
|
||||
});
|
||||
}
|
||||
@@ -2595,7 +2694,7 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementProperty(
|
||||
button,
|
||||
'textContent',
|
||||
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
|
||||
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
|
||||
);
|
||||
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
|
||||
}
|
||||
@@ -2961,13 +3060,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.progressInput,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? 'Seek Nepal scene clock'
|
||||
: 'Schematic downstream progression',
|
||||
? t('Seek Nepal scene clock')
|
||||
: t('Schematic downstream progression'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.timelineLabel,
|
||||
'textContent',
|
||||
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
|
||||
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
|
||||
);
|
||||
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
|
||||
setElementProperty(
|
||||
@@ -2980,22 +3079,22 @@ export function createBhoteKoshiEventLayer({
|
||||
);
|
||||
const playLabel = sceneDirected
|
||||
? _sceneActionPending && _sceneActionPending.type !== 'scene'
|
||||
? '… PLAYING SHOT'
|
||||
: '▶ PLAY SHOT'
|
||||
? t('… PLAYING SHOT')
|
||||
: t('▶ PLAY SHOT')
|
||||
: _playing
|
||||
? 'Ⅱ PAUSE'
|
||||
? t('Ⅱ PAUSE')
|
||||
: _progress >= 1
|
||||
? _cinematicReplayArmed
|
||||
? '↺ REPLAY CINEMATIC'
|
||||
: '↺ REPLAY'
|
||||
: '▶ PLAY';
|
||||
? t('↺ REPLAY CINEMATIC')
|
||||
: t('↺ REPLAY')
|
||||
: t('▶ PLAY');
|
||||
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
'textContent',
|
||||
_sceneActionPending?.type === 'scene'
|
||||
? '… PLAYING SCENE'
|
||||
: '▶ PLAY SCENE',
|
||||
? t('… PLAYING SCENE')
|
||||
: t('▶ PLAY SCENE'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.playSceneButton,
|
||||
@@ -3011,19 +3110,24 @@ export function createBhoteKoshiEventLayer({
|
||||
setElementAttribute(
|
||||
_panelRefs.playSceneButton,
|
||||
'aria-label',
|
||||
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
|
||||
t('Continue {scene} from next shot', {
|
||||
scene:
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('current scene'),
|
||||
}),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.storyReplayButton,
|
||||
'textContent',
|
||||
'↺ FULL STORY',
|
||||
t('↺ FULL STORY'),
|
||||
);
|
||||
if (!sceneDirected) {
|
||||
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
|
||||
setElementAttribute(
|
||||
_panelRefs.storyReplayButton,
|
||||
'aria-label',
|
||||
'Replay the full reconstruction',
|
||||
t('Replay the full reconstruction'),
|
||||
);
|
||||
}
|
||||
const beatIndex = currentEvidenceIndex();
|
||||
@@ -3047,18 +3151,26 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatTitle,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? _sceneContext?.shotTitle || 'SCENE SHOT'
|
||||
: observation?.shortTitle || observation?.title || 'EVIDENCE',
|
||||
? _sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('SCENE SHOT')
|
||||
: observation?.shortTitle || observation?.title
|
||||
? bhoteEventText(observation.shortTitle || observation.title)
|
||||
: t('EVIDENCE'),
|
||||
);
|
||||
const sourceCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
|
||||
);
|
||||
const beatMeta = [
|
||||
String(observation?.phase || 'EVIDENCE').toUpperCase(),
|
||||
'TIME UNVERIFIED',
|
||||
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
|
||||
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
|
||||
evidencePhaseLabel(observation?.phase),
|
||||
t('TIME UNVERIFIED'),
|
||||
sourceCount > 1
|
||||
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
|
||||
: '',
|
||||
observation?.imageryCoverage === 'outside'
|
||||
? t('OUTSIDE IMAGERY SWIPE')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
@@ -3066,7 +3178,13 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.beatMeta,
|
||||
'textContent',
|
||||
sceneDirected
|
||||
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
|
||||
? t('{scene} / AUTHORED SHOT', {
|
||||
scene: String(
|
||||
_sceneContext?.sceneDisplayTitle ||
|
||||
_sceneContext?.sceneTitle ||
|
||||
t('NEPAL FLOOD INCIDENT'),
|
||||
).toUpperCase(),
|
||||
})
|
||||
: beatMeta,
|
||||
);
|
||||
setElementProperty(
|
||||
@@ -3093,18 +3211,23 @@ export function createBhoteKoshiEventLayer({
|
||||
_panelRefs.playButton,
|
||||
'aria-label',
|
||||
sceneDirected
|
||||
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
|
||||
: 'Play reconstruction',
|
||||
? t('Play {shot}', {
|
||||
shot:
|
||||
_sceneContext?.shotDisplayTitle ||
|
||||
_sceneContext?.shotTitle ||
|
||||
t('current scene shot'),
|
||||
})
|
||||
: t('Play reconstruction'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.previousButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
|
||||
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
|
||||
);
|
||||
setElementAttribute(
|
||||
_panelRefs.nextButton,
|
||||
'aria-label',
|
||||
sceneDirected ? 'Load next scene shot' : 'Next story beat',
|
||||
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
|
||||
);
|
||||
setElementProperty(
|
||||
_panelRefs.openSourceButton,
|
||||
@@ -3153,8 +3276,8 @@ export function createBhoteKoshiEventLayer({
|
||||
if (_afterLayer) _afterLayer.show = imageryAvailable;
|
||||
if (_panelRefs?.caveat) {
|
||||
_panelRefs.caveat.textContent = imageryAvailable
|
||||
? _event?.reconstruction?.caveat || ''
|
||||
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
|
||||
? bhoteEventText(_event?.reconstruction?.caveat || '')
|
||||
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
|
||||
}
|
||||
if (_panelRefs?.imageryCloudNote)
|
||||
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
|
||||
@@ -3717,7 +3840,9 @@ export function createBhoteKoshiEventLayer({
|
||||
count: _enabled ? 1 : 0,
|
||||
status: _enabled ? 'nominal' : 'idle',
|
||||
source: 'Vantor + GeoPera',
|
||||
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
|
||||
coverage: _enabled
|
||||
? t('Rasuwa · 26 Aug 2026')
|
||||
: t('Event reconstruction'),
|
||||
};
|
||||
},
|
||||
getPlaybackState() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './dataCredits.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import { t, msg, getLocale } from '../i18n/index.js';
|
||||
|
||||
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
|
||||
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
|
||||
@@ -51,23 +52,43 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
|
||||
const TRIGGER_SOURCE_URL =
|
||||
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
|
||||
|
||||
// Overview pin titles that are more than a place name translate at display,
|
||||
// through the same entries the event panel uses. The data stays English and
|
||||
// place names stay Latin; everything else shows as authored.
|
||||
const PLACE_TITLE_TEXT = new Map(
|
||||
[
|
||||
msg('Collapse viewpoint'),
|
||||
msg('Dammed lake'),
|
||||
msg('Second landslide'),
|
||||
msg('Border gate'),
|
||||
msg('Dandagaun viewpoint'),
|
||||
msg('Bidur bridge'),
|
||||
msg('Devighat bridge'),
|
||||
].map((text) => [text.toUpperCase(), text]),
|
||||
);
|
||||
|
||||
function placeTitle(title) {
|
||||
const text = PLACE_TITLE_TEXT.get(title);
|
||||
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
|
||||
}
|
||||
|
||||
const INCIDENT = Object.freeze({
|
||||
lat: 28.3632333,
|
||||
lon: 85.4423639,
|
||||
heightM: 5200,
|
||||
title: 'BHOTE KOSHI FLOOD INCIDENT',
|
||||
subtitle: 'NEPAL · 26 AUG 2026',
|
||||
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
|
||||
subtitle: msg('NEPAL · 26 AUG 2026'),
|
||||
});
|
||||
const TRIGGER_RECORD = Object.freeze({
|
||||
lat: 28.271,
|
||||
lon: 85.515,
|
||||
heightM: 4200,
|
||||
title: 'USGS INITIAL REPORT',
|
||||
title: msg('USGS INITIAL REPORT'),
|
||||
details: Object.freeze([
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
'28.2710° N · 85.5150° E',
|
||||
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
|
||||
'USGS EVENT us7000tbwb · OPEN SOURCE',
|
||||
msg('26 AUG 2026 · 08:37 NPT'),
|
||||
msg('28.2710° N · 85.5150° E'),
|
||||
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
|
||||
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
|
||||
]),
|
||||
});
|
||||
const TRIGGER_APPROACH_CAMERA = Object.freeze({
|
||||
@@ -90,11 +111,13 @@ const NEPAL = Object.freeze({
|
||||
lat: 28.3949,
|
||||
lon: 84.124,
|
||||
heightM: 6800,
|
||||
// i18n-ignore: country name, shown as is
|
||||
title: 'NEPAL',
|
||||
});
|
||||
const NEARBY_CITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'kathmandu',
|
||||
// i18n-ignore: city name
|
||||
title: 'KATHMANDU',
|
||||
lat: 27.7017,
|
||||
lon: 85.3206,
|
||||
@@ -103,6 +126,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'bhaktapur',
|
||||
// i18n-ignore: city name
|
||||
title: 'BHAKTAPUR',
|
||||
lat: 27.673,
|
||||
lon: 85.43,
|
||||
@@ -111,6 +135,7 @@ const NEARBY_CITIES = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'dhulikhel',
|
||||
// i18n-ignore: city name
|
||||
title: 'DHULIKHEL',
|
||||
lat: 27.6221,
|
||||
lon: 85.5428,
|
||||
@@ -394,8 +419,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: subject.title,
|
||||
details: [subject.subtitle],
|
||||
title: t(subject.title),
|
||||
details: [t(subject.subtitle)],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -536,8 +561,8 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'card',
|
||||
paintLane: 'thumbnail',
|
||||
title: TRIGGER_RECORD.title,
|
||||
details: [...TRIGGER_RECORD.details],
|
||||
title: t(TRIGGER_RECORD.title),
|
||||
details: TRIGGER_RECORD.details.map((line) => t(line)),
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: () => presentation().alpha,
|
||||
presentationScale: () => presentation().scale,
|
||||
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
|
||||
details: [],
|
||||
accent: INCIDENT_ACCENT,
|
||||
sourceAlpha: progress,
|
||||
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'nepal-context',
|
||||
// i18n-ignore: country name
|
||||
label: 'NEPAL',
|
||||
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Show the Nepal context callout without a border or country fill',
|
||||
),
|
||||
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-regional',
|
||||
// i18n-ignore: river name (Bhote Koshi)
|
||||
label: 'BHOTE',
|
||||
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-city-context',
|
||||
label: 'CITIES',
|
||||
label: t('CITIES'),
|
||||
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Keep the regional incident context and introduce nearby city labels',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-incident-places',
|
||||
label: 'PLACES',
|
||||
label: t('PLACES'),
|
||||
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Introduce the reconstruction scene key places from trigger to downstream',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-flood-path',
|
||||
label: 'PATH',
|
||||
label: t('PATH'),
|
||||
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-path-overview',
|
||||
label: 'OVERVIEW',
|
||||
label: t('OVERVIEW'),
|
||||
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
title: 'Keep the completed flood path and location labels visible',
|
||||
title: t(
|
||||
'Keep the completed flood path and location labels visible',
|
||||
),
|
||||
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
|
||||
},
|
||||
{
|
||||
id: 'bhote-koshi-trigger-record',
|
||||
label: 'RECORD',
|
||||
label: t('RECORD'),
|
||||
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
title:
|
||||
title: t(
|
||||
'Reveal the trigger record while making a slow camera approach',
|
||||
),
|
||||
params: {
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
preview: true,
|
||||
|
||||
+408
-121
@@ -1,9 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
|
||||
import {
|
||||
INCIDENT_OVERVIEW_HOLD_SEC,
|
||||
INCIDENT_OVERVIEW_PLACES,
|
||||
} from './bhoteKoshiIncidentPlaces.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { createPlaybackClock } from '../director/clock.js';
|
||||
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
|
||||
@@ -29,8 +33,12 @@ function viewerFixture() {
|
||||
return {
|
||||
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
|
||||
dataSources: {
|
||||
add(source) { added.push(source); },
|
||||
remove(source) { removed.push(source); },
|
||||
add(source) {
|
||||
added.push(source);
|
||||
},
|
||||
remove(source) {
|
||||
removed.push(source);
|
||||
},
|
||||
},
|
||||
_test: { added, removed },
|
||||
};
|
||||
@@ -38,7 +46,9 @@ function viewerFixture() {
|
||||
|
||||
function nonRunningAnimation() {
|
||||
return {
|
||||
scheduleFrame() { return 1; },
|
||||
scheduleFrame() {
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
};
|
||||
}
|
||||
@@ -58,24 +68,68 @@ function overlayHostFixture() {
|
||||
}
|
||||
|
||||
test('Bhote Koshi locator closes valid region rings once', () => {
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
|
||||
[80, 26], [88, 26], [88, 30], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
|
||||
[80, 26], [88, 26], [80, 26],
|
||||
]);
|
||||
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
]),
|
||||
[
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[80, 26],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
closeRegionRing([
|
||||
[80, 26],
|
||||
['bad', 27],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
|
||||
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
|
||||
const island = [[82, 25], [83, 25], [82, 25]];
|
||||
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
|
||||
assert.equal(largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}), main);
|
||||
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
|
||||
const main = [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
[80, 26],
|
||||
];
|
||||
const island = [
|
||||
[82, 25],
|
||||
[83, 25],
|
||||
[82, 25],
|
||||
];
|
||||
assert.equal(
|
||||
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
|
||||
main,
|
||||
);
|
||||
assert.equal(
|
||||
largestGeoJsonRing({
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[island], [main]],
|
||||
}),
|
||||
main,
|
||||
);
|
||||
assert.deepEqual(
|
||||
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
|
||||
new Cesium.Cartesian3(10, 0, 0),
|
||||
new Cesium.Cartesian3(20, 0, 0),
|
||||
];
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0), [
|
||||
positions[0],
|
||||
positions[0],
|
||||
]);
|
||||
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
|
||||
positions[0],
|
||||
new Cesium.Cartesian3(5, 0, 0),
|
||||
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
overlayHost,
|
||||
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(publication.entries.length, 2);
|
||||
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
|
||||
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const nepal = publication.entries.find(
|
||||
({ id }) => id === 'nepal-context-callout',
|
||||
);
|
||||
const incident = publication.entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
assert.equal(nepal.variant, 'label');
|
||||
assert.equal(nepal.title, 'NEPAL');
|
||||
assert.equal(nepal.anchorDot, true);
|
||||
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(incident.presentationScale(), 1);
|
||||
assert.equal(layer.getStats().status, 'nominal');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
|
||||
});
|
||||
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
|
||||
|
||||
assert.equal(await layer.disable(), true);
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
|
||||
assert.equal(
|
||||
overlayHost._test.clears.at(-1),
|
||||
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'idle');
|
||||
});
|
||||
|
||||
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
|
||||
assert.equal(nepalLabel.variant, 'label');
|
||||
assert.equal(nepalLabel.placement, 'right');
|
||||
assert.equal(nepalLabel.anchorDot, true);
|
||||
assert.equal(layer.getStats().coverage, 'Nepal context');
|
||||
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
|
||||
[
|
||||
{ id: 'nepal-context', active: true },
|
||||
{ id: 'bhote-koshi-regional', active: false },
|
||||
{ id: 'bhote-koshi-city-context', active: false },
|
||||
{ id: 'bhote-koshi-incident-places', active: false },
|
||||
{ id: 'bhote-koshi-flood-path', active: false },
|
||||
{ id: 'bhote-koshi-path-overview', active: false },
|
||||
{ id: 'bhote-koshi-trigger-record', active: false },
|
||||
],
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
overlayHost._test.publications.at(-1).entries
|
||||
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
|
||||
'card',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
|
||||
'nepal-context-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
viewer._test.added[0].entities.values.map((entity) => entity.id),
|
||||
['nepal-context-halo'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
let nowMs = 0;
|
||||
let pulseFrame = null;
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null, requestRender() {},
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => nowMs,
|
||||
scheduleFrame(callback) { pulseFrame = callback; return 1; },
|
||||
scheduleFrame(callback) {
|
||||
pulseFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame() {},
|
||||
});
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const source = viewer._test.added[0];
|
||||
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
|
||||
const event = JSON.parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
'../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(event.evidenceSpine.length, 16);
|
||||
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
|
||||
assert.equal(source.entities.values.length, 15);
|
||||
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
|
||||
const id = `bhote-koshi-place-${point.id}`;
|
||||
const pin = labels.find((entry) => entry.id === id);
|
||||
assert.ok(pin, point.id);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
|
||||
);
|
||||
assert.equal(pin.variant, 'label');
|
||||
const location = Cesium.Cartographic.fromCartesian(pin.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
|
||||
);
|
||||
nowMs = 349 + index * 650;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
nowMs += 1;
|
||||
assert.equal(pin.sourceAlpha(), 0);
|
||||
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
|
||||
assert.equal(
|
||||
source.entities.values.filter(
|
||||
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
nowMs += 520;
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
|
||||
// A repeat halo cycle must not erase already revealed labels.
|
||||
nowMs = 350 + 15 * 650;
|
||||
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
|
||||
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
|
||||
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
|
||||
assert.equal(
|
||||
source.entities
|
||||
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
|
||||
.show.getValue(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
labels
|
||||
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
|
||||
.sourceAlpha(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
|
||||
false,
|
||||
);
|
||||
assert.equal(typeof pulseFrame, 'function');
|
||||
pulseFrame();
|
||||
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
|
||||
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
|
||||
assert.equal(
|
||||
source.entities.values.some((entity) =>
|
||||
entity.id.startsWith('bhote-koshi-place-'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
now: () => 0,
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
await layer.enable(viewer);
|
||||
const titles = new Map(
|
||||
overlayHost._test.publications
|
||||
.at(-1)
|
||||
.entries.map(({ id, title }) => [id, title]),
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
|
||||
'01 · ВИД НА ОБРУШЕНИЕ',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-gyirong-border-gate'),
|
||||
'04 · ПОГРАНПЕРЕХОД',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
|
||||
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
|
||||
);
|
||||
assert.equal(
|
||||
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
|
||||
'14 · МОСТ BIDUR',
|
||||
);
|
||||
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const source = viewer._test.added[0];
|
||||
const path = source.entities.getById('bhote-koshi-flood-path');
|
||||
const labels = overlayHost._test.publications.at(-1).entries;
|
||||
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
|
||||
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
|
||||
const trigger = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
|
||||
);
|
||||
const bidur = labels.find(
|
||||
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
|
||||
);
|
||||
|
||||
assert.equal(source.entities.values.length, 1);
|
||||
assert.equal(labels.length, 15);
|
||||
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
}
|
||||
assert.equal(path.polyline.clampToGround.getValue(), true);
|
||||
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
|
||||
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
));
|
||||
assert.ok(
|
||||
path.polyline.material.color
|
||||
.getValue()
|
||||
.equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
|
||||
Cesium.Math.EPSILON7,
|
||||
),
|
||||
);
|
||||
assert.equal(path.show.getValue(), false);
|
||||
assert.equal(trigger.sourceAlpha(), 1);
|
||||
assert.equal(bidur.sourceAlpha(), 1);
|
||||
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
|
||||
const halfwayCount = path.polyline.positions.getValue().length;
|
||||
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
|
||||
nowMs = 4250;
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
|
||||
// More observation pins must not extend or straighten the sourced route.
|
||||
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat)
|
||||
)));
|
||||
assert.deepEqual(
|
||||
path.polyline.positions.getValue(),
|
||||
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
|
||||
assert.equal(labels.length, 15);
|
||||
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
|
||||
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
|
||||
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
|
||||
assert.equal(
|
||||
pin.title,
|
||||
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
|
||||
);
|
||||
assert.equal(pin.sourceAlpha(), 1);
|
||||
}
|
||||
assert.equal(path.show.getValue(), true);
|
||||
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
|
||||
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
|
||||
assert.equal(
|
||||
path.polyline.positions.getValue().length,
|
||||
BHOTE_KOSHI_FLOOD_PATH.length,
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
path.polyline.positions.getValue()[0],
|
||||
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
|
||||
),
|
||||
);
|
||||
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
|
||||
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(
|
||||
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
|
||||
source.entities.getById(
|
||||
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
|
||||
const publication = overlayHost._test.publications.at(-1);
|
||||
const record = publication.entries[0];
|
||||
|
||||
assert.deepEqual(source.entities.values.map(({ id }) => id), [
|
||||
'bhote-koshi-flood-path',
|
||||
'bhote-koshi-trigger-record-halo',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
source.entities.values.map(({ id }) => id),
|
||||
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
|
||||
);
|
||||
assert.equal(record.title, 'USGS INITIAL REPORT');
|
||||
assert.deepEqual(record.details, [
|
||||
'26 AUG 2026 · 08:37 NPT',
|
||||
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
viewer.camera = {
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights[0].duration, 4.6);
|
||||
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
|
||||
);
|
||||
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
|
||||
flights[0].complete();
|
||||
assert.equal(flights.length, 2);
|
||||
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
const moveStart = new Cesium.Event();
|
||||
const moveEnd = new Cesium.Event();
|
||||
viewer.camera = {
|
||||
moveStart, moveEnd,
|
||||
flyTo(options) { flights.push(options); },
|
||||
moveStart,
|
||||
moveEnd,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
cancelDelay(id) { timers.delete(id); },
|
||||
cancelDelay(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
...nonRunningAnimation(),
|
||||
});
|
||||
const director = {
|
||||
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
|
||||
_clock: createPlaybackClock({
|
||||
isRunning: () => false,
|
||||
timingForShot: () => null,
|
||||
onProgress() {},
|
||||
}),
|
||||
_scenePacks: createDefaultScenePacks(),
|
||||
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
};
|
||||
try {
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
await layer.enable(viewer);
|
||||
if (phase === 'move-end') moveStart.raiseEvent();
|
||||
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
|
||||
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
|
||||
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
|
||||
assert.equal(
|
||||
flights.length,
|
||||
oldFlights.length,
|
||||
'no post-Stop approach or orbit',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
1,
|
||||
'Stop preserves the visible locator',
|
||||
);
|
||||
|
||||
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
|
||||
layer.setParams({
|
||||
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
|
||||
});
|
||||
callbacks.at(-1)();
|
||||
const successor = flights.at(-1);
|
||||
const count = flights.length;
|
||||
callbacks[0]();
|
||||
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
|
||||
for (const flight of oldFlights) {
|
||||
flight.cancel();
|
||||
flight.complete();
|
||||
}
|
||||
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
|
||||
successor.complete();
|
||||
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
|
||||
} finally { await layer.destroy(); }
|
||||
assert.equal(
|
||||
flights.length,
|
||||
count + 1,
|
||||
'successor retains its own orbit',
|
||||
);
|
||||
} finally {
|
||||
await layer.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => ({
|
||||
name: 'Nepal',
|
||||
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
}),
|
||||
requestRender() {},
|
||||
overlayHost,
|
||||
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
const source = viewer._test.added[0];
|
||||
const borderFill = source.entities.getById('nepal-border-fill');
|
||||
const entries = overlayHost._test.publications.at(-1).entries;
|
||||
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
|
||||
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
|
||||
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
|
||||
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
|
||||
const incident = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-incident-callout',
|
||||
);
|
||||
const kathmandu = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-kathmandu',
|
||||
);
|
||||
const bhaktapur = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-bhaktapur',
|
||||
);
|
||||
const dhulikhel = entries.find(
|
||||
({ id }) => id === 'bhote-koshi-city-dhulikhel',
|
||||
);
|
||||
|
||||
assert.equal(borderFill.show.getValue(), true);
|
||||
assert.equal(incident.contentAlpha(), 1);
|
||||
assert.equal(kathmandu.title, 'KATHMANDU');
|
||||
assert.equal(bhaktapur.title, 'BHAKTAPUR');
|
||||
assert.equal(dhulikhel.title, 'DHULIKHEL');
|
||||
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
|
||||
variant === 'label' && anchorDot === true
|
||||
)));
|
||||
assert.ok(
|
||||
[kathmandu, bhaktapur, dhulikhel].every(
|
||||
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
|
||||
),
|
||||
);
|
||||
assert.equal(kathmandu.sourceAlpha(), 0);
|
||||
assert.equal(bhaktapur.sourceAlpha(), 0);
|
||||
assert.equal(dhulikhel.sourceAlpha(), 0);
|
||||
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
|
||||
nowMs = 1300;
|
||||
assert.equal(dhulikhel.sourceAlpha(), 1);
|
||||
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
|
||||
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
|
||||
});
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
|
||||
});
|
||||
try {
|
||||
await layer.enable(viewer);
|
||||
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
|
||||
const ids = viewer._test.added[0].entities.values.map(
|
||||
(entity) => entity.id,
|
||||
);
|
||||
assert.ok(ids.includes('nepal-context-halo'));
|
||||
assert.ok(ids.includes('bhote-koshi-incident-halo'));
|
||||
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
|
||||
assert.equal(ids.includes('nepal-border-highlight'), false);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
assert.equal(layer.getStats().status, 'degraded');
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
const viewer = viewerFixture();
|
||||
const overlayHost = overlayHostFixture();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
|
||||
|
||||
const enabling = layer.enable(viewer);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('nepal-border-highlight'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 1);
|
||||
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications[0].entries.map(({ id }) => id),
|
||||
['nepal-context-callout'],
|
||||
);
|
||||
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
[80, 30],
|
||||
],
|
||||
});
|
||||
await enabling;
|
||||
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
|
||||
assert.ok(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
|
||||
);
|
||||
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
|
||||
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
|
||||
assert.equal(
|
||||
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(overlayHost._test.publications.length, 2);
|
||||
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
|
||||
'nepal-context-callout',
|
||||
'bhote-koshi-incident-callout',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
|
||||
['nepal-context-callout', 'bhote-koshi-incident-callout'],
|
||||
);
|
||||
await layer.destroy();
|
||||
});
|
||||
|
||||
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
const viewer = viewerFixture();
|
||||
const controller = new AbortController();
|
||||
let releaseBoundary;
|
||||
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
|
||||
const boundary = new Promise((resolve) => {
|
||||
releaseBoundary = resolve;
|
||||
});
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => boundary,
|
||||
requestRender() {},
|
||||
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
|
||||
});
|
||||
const enabling = layer.enable(viewer, { signal: controller.signal });
|
||||
controller.abort();
|
||||
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
|
||||
releaseBoundary({
|
||||
ring: [
|
||||
[80, 26],
|
||||
[88, 26],
|
||||
[88, 30],
|
||||
],
|
||||
});
|
||||
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
|
||||
assert.equal(viewer._test.removed.length, 1);
|
||||
});
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
createApplicationDirectionsLayer,
|
||||
} from './directions.js';
|
||||
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
|
||||
import { instructionFor } from './routeSteps.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
directionsInstruction,
|
||||
routeDistanceText,
|
||||
routeDurationText,
|
||||
} from '../layers/directions/index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
isPointerFree,
|
||||
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
|
||||
lastUpdate: 5,
|
||||
});
|
||||
assert.equal(ready.count, 3);
|
||||
assert.equal(ready.coverage, '21 km · 25 min · Drive');
|
||||
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
|
||||
assert.equal(
|
||||
ready.loadingLabel,
|
||||
'21 km · 25 min · Drive',
|
||||
'21\u00a0km · 25\u00a0min · Drive',
|
||||
'the summary is the row detail line',
|
||||
);
|
||||
assert.equal(ready.loading, undefined);
|
||||
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
|
||||
const copy = directionsStepCopy(route.steps, 1);
|
||||
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
|
||||
assert.deepEqual(copy.details, [
|
||||
'Step 2 of 3 · then 240 m · 39 s',
|
||||
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
|
||||
'Then: Arrive at your destination',
|
||||
]);
|
||||
const last = directionsStepCopy(route.steps, 2);
|
||||
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.lead),
|
||||
['8 m', '240 m', '0 m'],
|
||||
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.items.map((item) => item.params),
|
||||
@@ -773,3 +780,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
|
||||
assert.equal(toasts.length, 1, 'a successful arming is silent');
|
||||
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
|
||||
});
|
||||
|
||||
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
|
||||
const types = [
|
||||
'depart',
|
||||
'arrive',
|
||||
'turn',
|
||||
'new name',
|
||||
'continue',
|
||||
'end of road',
|
||||
'fork',
|
||||
'merge',
|
||||
'on ramp',
|
||||
'off ramp',
|
||||
'roundabout',
|
||||
'rotary',
|
||||
'roundabout turn',
|
||||
'exit roundabout',
|
||||
'exit rotary',
|
||||
'use lane',
|
||||
'notification',
|
||||
'something new',
|
||||
];
|
||||
const modifiers = [
|
||||
null,
|
||||
'left',
|
||||
'right',
|
||||
'slight left',
|
||||
'slight right',
|
||||
'sharp left',
|
||||
'sharp right',
|
||||
'straight',
|
||||
'uturn',
|
||||
];
|
||||
const roads = [
|
||||
{ name: '', ref: null },
|
||||
{ name: 'Congress Avenue', ref: null },
|
||||
{ name: 'Mannerheimintie', ref: 'E12' },
|
||||
{ name: '', ref: 'I 35' },
|
||||
];
|
||||
for (const type of types)
|
||||
for (const modifier of modifiers)
|
||||
for (const road of roads)
|
||||
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
|
||||
const step = { type, modifier, exit, ...road };
|
||||
assert.equal(
|
||||
directionsInstruction(step),
|
||||
instructionFor(step),
|
||||
JSON.stringify(step),
|
||||
);
|
||||
}
|
||||
// An older payload without a maneuver type keeps the proxy's sentence.
|
||||
assert.equal(
|
||||
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
|
||||
'Head out on Kaivokatu',
|
||||
);
|
||||
});
|
||||
|
||||
test('route numbers follow the page language and never break from their unit', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
assert.equal(routeDistanceText(8.4), '8\u00a0m');
|
||||
assert.equal(routeDistanceText(236), '240\u00a0m');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39\u00a0s');
|
||||
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
|
||||
assert.equal(routeDurationText(7200), '2\u00a0h');
|
||||
assert.equal(routeDistanceText(-1), '');
|
||||
assert.equal(routeDurationText(Number.NaN), '');
|
||||
|
||||
setLocale('ru');
|
||||
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
|
||||
assert.equal(routeDurationText(240), '4\u00a0мин');
|
||||
assert.equal(
|
||||
directionsInstruction({
|
||||
type: 'turn',
|
||||
modifier: 'right',
|
||||
name: 'East 7th Street',
|
||||
}),
|
||||
'Поверните направо на East 7th Street',
|
||||
);
|
||||
assert.equal(
|
||||
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
|
||||
'На кольце сверните на 2-й съезд',
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// Marks restore labels for i18n extraction; shareRestoration translates them.
|
||||
// Kept local so this module stays free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
@@ -458,19 +461,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedFlightsTrackingId',
|
||||
expiryWindowMs: 90_000,
|
||||
label: 'flight',
|
||||
label: msg('flight'),
|
||||
}),
|
||||
military: Object.freeze({
|
||||
optionOwner: 'flights',
|
||||
optionKey: 'selectedMilitaryTrackingId',
|
||||
expiryWindowMs: 45_000,
|
||||
label: 'military flight',
|
||||
label: msg('military flight'),
|
||||
}),
|
||||
satellites: Object.freeze({
|
||||
optionOwner: 'satellites',
|
||||
optionKey: 'selectedSatTrackingId',
|
||||
expiryWindowMs: 300_000,
|
||||
label: 'satellite',
|
||||
label: msg('satellite'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+17
-12
@@ -26,47 +26,47 @@ import { t, msg } from '../i18n/index.js';
|
||||
*/
|
||||
export const SATELLITE_CLASSES = Object.freeze({
|
||||
station: Object.freeze({
|
||||
label: 'STATION',
|
||||
label: 'STATION', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Warm white — "crewed", and the brightest class in the sky. Deliberately
|
||||
// NOT amber: 40-48deg amber is the app's known-military convention
|
||||
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
|
||||
// leak into another domain. Slightly warm so it also separates from the
|
||||
// pure white the civilian air fleet draws in.
|
||||
color: '#fff6e5',
|
||||
blurb: 'Crewed stations and their visiting vehicles',
|
||||
blurb: 'Crewed stations and their visiting vehicles', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
nav: Object.freeze({
|
||||
label: 'NAV',
|
||||
label: 'NAV', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Cyan — the strongest accent goes to the class users actually name
|
||||
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
|
||||
// VISUAL group used to occupy, so this adds no new hue to the app.
|
||||
color: '#4fd8ff',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
|
||||
blurb: 'GNSS navigation — GPS, GLONASS, Galileo', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
geo: Object.freeze({
|
||||
label: 'GEO',
|
||||
label: 'GEO', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Violet, unchanged. Already the app's "space" semantic (the detection
|
||||
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
|
||||
// as one clean equatorial ring at globe scale.
|
||||
color: '#c89bff',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
|
||||
blurb: 'Geostationary belt — comms and weather, fixed over the equator', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
visual: Object.freeze({
|
||||
label: 'VISUAL',
|
||||
label: 'VISUAL', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
|
||||
// cyan it used to hold and recedes behind the three classes that mean
|
||||
// something. Still well clear of the dense shell by luminance.
|
||||
color: '#9fb3c4',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
|
||||
blurb: 'Brightest naked-eye objects — CelesTrak visual group', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
comms: Object.freeze({
|
||||
label: 'COMMS',
|
||||
label: 'COMMS', // i18n-ignore: English token; shown through CLASS_DISPLAY_KEYS
|
||||
// Dim slate — thousands of these appear in DENSE mode. They must read as
|
||||
// texture behind the core catalog, never compete with it. Rec.601 luma
|
||||
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
|
||||
// NVG/FLIR shaders collapse the scene to a single channel.
|
||||
color: '#54697f',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode',
|
||||
blurb: 'Broadband constellation shell — shown only in DENSE mode', // i18n-ignore: shown through CLASS_DISPLAY_KEYS
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -167,9 +167,14 @@ export function satelliteClassColor(group) {
|
||||
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function satelliteClassLabel(
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const { klass, subtype } = satelliteClassOf(group, { isIss });
|
||||
const base = t(CLASS_DISPLAY_KEYS[klass].label);
|
||||
// localize: false keeps the English label for data the AI tools query.
|
||||
const label = CLASS_DISPLAY_KEYS[klass].label;
|
||||
const base = localize ? t(label) : label;
|
||||
return subtype ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
|
||||
|
||||
+483
-144
@@ -52,13 +52,23 @@ test('dismissal is session-scoped; only the checkbox suppresses durably', () =>
|
||||
rememberFirstRunSessionDismissed(session);
|
||||
assert.equal(session.read(), 'dismissed');
|
||||
// Gone for THIS session...
|
||||
assert.equal(shouldShowFirstRun({ storage, sessionStorageRef: session, location: { search: '' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: session,
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// ...and back in the next one, because sessionStorage did not survive it.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Session dismissal must never have written the durable key.
|
||||
assert.equal(storage.read(), null);
|
||||
});
|
||||
@@ -67,43 +77,65 @@ test('the checkbox writes and clears durable suppression, and a storage reset un
|
||||
const storage = memoryStorage(FIRST_RUN_STORAGE_KEY);
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(storage.read(), 'suppressed');
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
// Unticking before dismissing takes the suppression back.
|
||||
setFirstRunSuppressed(false, storage);
|
||||
assert.equal(storage.read(), null);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage,
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// A cleared/hard-reset profile shows it again — an accepted, documented cost.
|
||||
setFirstRunSuppressed(true, storage);
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: memoryStorage(FIRST_RUN_STORAGE_KEY),
|
||||
sessionStorageRef: memoryStorage(FIRST_RUN_SESSION_KEY),
|
||||
location: { search: '' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('welcome params work in both directions and outrank both suppressions', () => {
|
||||
const suppressed = memoryStorage(FIRST_RUN_STORAGE_KEY, 'suppressed');
|
||||
const dismissed = memoryStorage(FIRST_RUN_SESSION_KEY, 'dismissed');
|
||||
// ?welcome=1 replays past the checkbox AND past a session dismissal.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
storage: suppressed, sessionStorageRef: dismissed, location: { search: '?welcome=1' },
|
||||
}), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
storage: suppressed,
|
||||
sessionStorageRef: dismissed,
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// ?welcome=0 suppresses a session that would otherwise see it.
|
||||
assert.equal(shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ ...fresh(), location: { search: '?welcome=0' } }),
|
||||
false,
|
||||
);
|
||||
// A share link outranks everything, including the replay hatch.
|
||||
assert.equal(shouldShowFirstRun({
|
||||
hasShareState: true, ...fresh(), location: { search: '?welcome=1' },
|
||||
}), false);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({
|
||||
hasShareState: true,
|
||||
...fresh(),
|
||||
location: { search: '?welcome=1' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a share link never sees the launcher — its author already chose the view', () => {
|
||||
@@ -112,11 +144,20 @@ test('a share link never sees the launcher — its author already chose the view
|
||||
|
||||
test('privacy-restricted storage fails open and every write stays best-effort', () => {
|
||||
const blocked = {
|
||||
getItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
getItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }), true);
|
||||
assert.equal(
|
||||
shouldShowFirstRun({ storage: blocked, sessionStorageRef: blocked }),
|
||||
true,
|
||||
);
|
||||
assert.doesNotThrow(() => setFirstRunSuppressed(true, blocked));
|
||||
assert.doesNotThrow(() => rememberFirstRunSessionDismissed(blocked));
|
||||
});
|
||||
@@ -128,10 +169,15 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
// before the function body starts, so the SecurityError escapes every
|
||||
// try/catch in the module and the launcher silently never appears.
|
||||
const saved = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
const savedSession = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'sessionStorage',
|
||||
);
|
||||
const hostile = {
|
||||
configurable: true,
|
||||
get() { throw new Error('SecurityError: The operation is insecure.'); },
|
||||
get() {
|
||||
throw new Error('SecurityError: The operation is insecure.');
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', hostile);
|
||||
Object.defineProperty(globalThis, 'sessionStorage', hostile);
|
||||
@@ -152,16 +198,22 @@ test('a THROWING storage getter still fails open — Safari private mode', () =>
|
||||
} finally {
|
||||
if (saved) Object.defineProperty(globalThis, 'localStorage', saved);
|
||||
else delete globalThis.localStorage;
|
||||
if (savedSession) Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
if (savedSession)
|
||||
Object.defineProperty(globalThis, 'sessionStorage', savedSession);
|
||||
else delete globalThis.sessionStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test('no storage is touched from a default parameter position', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Comments stripped first: the block explaining this very defect quotes the
|
||||
// bad pattern, and matching prose instead of code would make the pin a liar.
|
||||
const code = module.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const code = module
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
/=\s*globalThis\.(local|session)Storage/,
|
||||
@@ -169,14 +221,24 @@ test('no storage is touched from a default parameter position', () => {
|
||||
);
|
||||
// The only reads of the global areas live inside the guarded resolver.
|
||||
const globalReads = [...code.matchAll(/globalThis\.(local|session)Storage/g)];
|
||||
assert.equal(globalReads.length, 2, 'exactly two global storage reads, both in resolveStore');
|
||||
const resolver = code.slice(code.indexOf('function resolveStore'), code.indexOf('function readStored'));
|
||||
assert.equal(
|
||||
globalReads.length,
|
||||
2,
|
||||
'exactly two global storage reads, both in resolveStore',
|
||||
);
|
||||
const resolver = code.slice(
|
||||
code.indexOf('function resolveStore'),
|
||||
code.indexOf('function readStored'),
|
||||
);
|
||||
assert.equal(
|
||||
[...resolver.matchAll(/globalThis\.(local|session)Storage/g)].length,
|
||||
2,
|
||||
'both global storage reads must be inside resolveStore, inside its try',
|
||||
);
|
||||
assert.match(resolver, /try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/);
|
||||
assert.match(
|
||||
resolver,
|
||||
/try \{[\s\S]*globalThis\.sessionStorage[\s\S]*\} catch/,
|
||||
);
|
||||
assert.match(code, /function readStored\(kind, injected, key\)/);
|
||||
assert.match(code, /function writeStored\(kind, injected, key, value\)/);
|
||||
});
|
||||
@@ -184,33 +246,52 @@ test('no storage is touched from a default parameter position', () => {
|
||||
// ── ESC arbitration: one surface, one key, never an invisible handler ────────
|
||||
|
||||
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.deepEqual(
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
['cockpit-mode', 'recording-mode', 'scene-playback-mode', 'ui-clean-view'],
|
||||
);
|
||||
assert.deepEqual([...EXCLUSIVE_SURFACE_CLASSES].sort(), [
|
||||
'cockpit-mode',
|
||||
'recording-mode',
|
||||
'scene-playback-mode',
|
||||
'ui-clean-view',
|
||||
]);
|
||||
// A surface that hides the card in CSS but is missing from the JS list would
|
||||
// leave an invisible launcher holding the ESC handler — blocker 2 exactly.
|
||||
const hideRule = css.slice(
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
css.indexOf('display: none', css.indexOf('body.ui-clean-view #first-run-launcher')),
|
||||
css.indexOf(
|
||||
'display: none',
|
||||
css.indexOf('body.ui-clean-view #first-run-launcher'),
|
||||
),
|
||||
);
|
||||
const inCss = [...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g)].map((m) => m[1]);
|
||||
const inCss = [
|
||||
...hideRule.matchAll(/body\.([a-z-]+) #first-run-launcher/g),
|
||||
].map((m) => m[1]);
|
||||
assert.deepEqual(
|
||||
inCss.sort(),
|
||||
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
|
||||
'every screen-claiming surface must appear in BOTH lists',
|
||||
);
|
||||
assert.match(module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/);
|
||||
assert.match(
|
||||
module,
|
||||
/export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
const make = (classes) => ({ body: { classList: { contains: (name) => classes.includes(name) } } });
|
||||
const make = (classes) => ({
|
||||
body: { classList: { contains: (name) => classes.includes(name) } },
|
||||
});
|
||||
assert.equal(exclusiveSurfaceActive(make([])), false);
|
||||
for (const name of EXCLUSIVE_SURFACE_CLASSES) {
|
||||
assert.equal(exclusiveSurfaceActive(make([name])), true, `${name} must count as exclusive`);
|
||||
assert.equal(
|
||||
exclusiveSurfaceActive(make([name])),
|
||||
true,
|
||||
`${name} must count as exclusive`,
|
||||
);
|
||||
}
|
||||
assert.equal(exclusiveSurfaceActive(make(['some-other-class'])), false);
|
||||
assert.equal(exclusiveSurfaceActive(undefined), false);
|
||||
@@ -218,14 +299,25 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
|
||||
});
|
||||
|
||||
test('the key handler refuses to act for a card that is not really on screen', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
|
||||
// Real visibility, not just the class: the class survives while CSS hides the
|
||||
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||
assert.match(module, /const isTopmost = \(\) =>\s*root\.isConnected/);
|
||||
assert.match(module, /&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/);
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
assert.match(
|
||||
module,
|
||||
/&&\s*root\.getClientRects\(\)\.length > 0 &&\s*!coveredByOverlay\(\);/,
|
||||
);
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/!isActive\(\)/,
|
||||
@@ -234,7 +326,10 @@ test('the key handler refuses to act for a card that is not really on screen', (
|
||||
});
|
||||
|
||||
test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
// The repro, pinned as the stacking it actually is: the attribution lightbox
|
||||
@@ -242,7 +337,10 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
// keeps its box, so getClientRects() alone called it visible, ESC dismissed a
|
||||
// buried launcher and wrote the session flag while the lightbox stayed open.
|
||||
const overlay = css.slice(css.indexOf('.cesium-credit-lightbox-overlay {'));
|
||||
assert.match(overlay.slice(0, overlay.indexOf('}')), /z-index: 200 !important/);
|
||||
assert.match(
|
||||
overlay.slice(0, overlay.indexOf('}')),
|
||||
/z-index: 200 !important/,
|
||||
);
|
||||
const launcher = css.slice(css.indexOf('#first-run-launcher {'));
|
||||
assert.match(launcher.slice(0, launcher.indexOf('}')), /z-index: 175/);
|
||||
|
||||
@@ -259,14 +357,26 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
||||
|
||||
// ...and every inconclusive answer is UNCOVERED, so a guard added to stop the
|
||||
// launcher acting under an overlay can never become why ESC stopped working.
|
||||
assert.match(covered, /if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/);
|
||||
assert.match(covered, /if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(typeof documentRef\.elementFromPoint !== 'function'\) return false;/,
|
||||
);
|
||||
assert.match(
|
||||
covered,
|
||||
/if \(!\(rect\.width > 0 && rect\.height > 0\)\) return false;/,
|
||||
);
|
||||
assert.match(covered, /\} catch \{\s*\n\s*return false;\s*\n\s*\}/);
|
||||
});
|
||||
|
||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/radioBindings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||
@@ -276,30 +386,45 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
|
||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||
assert.match(claim, /event\.preventDefault\(\);/);
|
||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||
assert.doesNotMatch(claim, /event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself');
|
||||
assert.doesNotMatch(
|
||||
claim,
|
||||
/event\.stopPropagation\(\);/,
|
||||
'the plain form leaves the launcher listening and is the defect itself',
|
||||
);
|
||||
|
||||
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||
// whether or not that surface remembered to silence us.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const handler = keyboard.slice(
|
||||
keyboard.indexOf('const onKeyDown = (event) => {'),
|
||||
);
|
||||
assert.match(
|
||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
||||
/event\.defaultPrevented\) return;/,
|
||||
'a marked key must be somebody else\'s key',
|
||||
"a marked key must be somebody else's key",
|
||||
);
|
||||
});
|
||||
|
||||
test('a refused write takes the tick back instead of promising "never again"', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The write stays best-effort; the OUTCOME is now reported, because a box left
|
||||
// ticked after a refused write tells the visitor the launcher is gone for good
|
||||
// while it is already guaranteed to return next session.
|
||||
const blocked = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error('blocked'); },
|
||||
removeItem: () => { throw new Error('blocked'); },
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(setFirstRunSuppressed(true, blocked), false);
|
||||
assert.equal(setFirstRunSuppressed(false, blocked), false);
|
||||
@@ -318,13 +443,26 @@ test('a refused write takes the tick back instead of promising "never again"', (
|
||||
module.indexOf('const onSuppressChange = (event) => {'),
|
||||
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
|
||||
);
|
||||
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
||||
assert.match(
|
||||
handler,
|
||||
/if \(setFirstRunSuppressed\(wanted, storage\)\) return;/,
|
||||
);
|
||||
assert.match(
|
||||
handler,
|
||||
/box\.checked = !wanted;/,
|
||||
'a refused write must revert the tick',
|
||||
);
|
||||
});
|
||||
|
||||
test('a surface class that never clears is an ACCEPTED no-show, not a timer', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const state = fs.readFileSync(new URL('../docs/CURRENT-STATE.md', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const state = fs.readFileSync(
|
||||
new URL('../docs/CURRENT-STATE.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// A "reveal anyway after N seconds" would trade a benign no-show for the card
|
||||
// punching through a recording in progress — recordings run long, and none of
|
||||
@@ -336,33 +474,65 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
|
||||
module.indexOf('ACCEPTED, DELIBERATELY NOT TIMED OUT'),
|
||||
module.indexOf(' const onViewportResize'),
|
||||
);
|
||||
assert.ok(accepted.length > 0, 'the acceptance must be written where the next editor will read it');
|
||||
assert.match(accepted, /const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note');
|
||||
assert.doesNotMatch(accepted, /setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out');
|
||||
assert.ok(
|
||||
accepted.length > 0,
|
||||
'the acceptance must be written where the next editor will read it',
|
||||
);
|
||||
assert.match(
|
||||
accepted,
|
||||
/const syncToExclusiveSurfaces = \(\) => \{/,
|
||||
'the pin must cover the function the note governs, not just the note',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
accepted,
|
||||
/setTimeout|setInterval/,
|
||||
'the acceptance is the decision NOT to time this out',
|
||||
);
|
||||
assert.match(accepted, /docs\/CURRENT-STATE\.md/);
|
||||
assert.match(state, /a surface class that never clears means no launcher for that page/i);
|
||||
assert.match(
|
||||
state,
|
||||
/a surface class that never clears means no launcher for that page/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('the scroll fade only appears when the list really overflows', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
// A fade on a card where all five tiles fit promises a sixth mission that does
|
||||
// not exist, which is worse than no affordance at all.
|
||||
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
|
||||
assert.match(module, /choiceList\.dataset\.scrollable = String\(overflows\);/);
|
||||
assert.match(
|
||||
module,
|
||||
/const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/,
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/choiceList\.dataset\.scrollable = String\(overflows\);/,
|
||||
);
|
||||
// ...and the CSS must be gated on that flag, never on the media query alone.
|
||||
assert.match(css, /\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/);
|
||||
const bareFade = css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(bareFade, /mask-image/, 'the fade must never apply unconditionally');
|
||||
assert.match(
|
||||
css,
|
||||
/\.first-run-choices\[data-scrollable='true'\] \{[\s\S]*?mask-image/,
|
||||
);
|
||||
const bareFade =
|
||||
css.match(/^\s*\.first-run-choices \{[\s\S]*?\n\}/m)?.[0] || '';
|
||||
assert.doesNotMatch(
|
||||
bareFade,
|
||||
/mask-image/,
|
||||
'the fade must never apply unconditionally',
|
||||
);
|
||||
// Rotating a phone changes which tiles fit, so it re-measures.
|
||||
assert.match(module, /addEventListener\?\.\('resize', onViewportResize\)/);
|
||||
assert.match(module, /removeEventListener\?\.\('resize', onViewportResize\)/);
|
||||
});
|
||||
|
||||
test('the launcher yields on engage and waits when a surface is already up', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// Both directions from one observer: yield if it is up and something takes
|
||||
// the screen; wait to reveal if something already has it.
|
||||
assert.match(
|
||||
@@ -375,19 +545,28 @@ test('the launcher yields on engage and waits when a surface is already up', ()
|
||||
// not see a new hold because of onboarding chrome.
|
||||
assert.match(module, /attributes: true,\s*attributeFilter: \['class'\]/);
|
||||
assert.match(module, /surfaceObserver\?\.disconnect\(\)/);
|
||||
assert.doesNotMatch(module, /setInterval|requestAnimationFrame\(function poll/);
|
||||
assert.doesNotMatch(
|
||||
module,
|
||||
/setInterval|requestAnimationFrame\(function poll/,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Per-mission behavior ─────────────────────────────────────────────────────
|
||||
|
||||
function missionSpy({ contextOk = true, layerResult = () => true, globe = async () => ({ ok: true }) } = {}) {
|
||||
function missionSpy({
|
||||
contextOk = true,
|
||||
layerResult = () => true,
|
||||
globe = async () => ({ ok: true }),
|
||||
} = {}) {
|
||||
const calls = { contextModes: [], layerIds: [], globeFlights: 0 };
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
setContextMode: async (mode) => {
|
||||
calls.contextModes.push(mode);
|
||||
return contextOk ? { ok: true, mode } : { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
return contextOk
|
||||
? { ok: true, mode }
|
||||
: { ok: false, failedLayerIds: ['rocket-launches'] };
|
||||
},
|
||||
setLayerEnabled: async (layerId) => {
|
||||
calls.layerIds.push(layerId);
|
||||
@@ -408,14 +587,23 @@ test('the menu is the four owner-ordered missions', () => {
|
||||
// went is the one-click globe-scale dump. Restoring the tile needs the
|
||||
// globe-LOD declutter first.
|
||||
assert.deepEqual(Object.keys(FIRST_RUN_MISSIONS), [
|
||||
'contacts', 'space-missions', 'environmental', 'explore',
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.equal(FIRST_RUN_MISSIONS.infrastructure, undefined,
|
||||
'the infrastructure mission must be gone, not dormant');
|
||||
assert.equal(
|
||||
FIRST_RUN_MISSIONS.infrastructure,
|
||||
undefined,
|
||||
'the infrastructure mission must be gone, not dormant',
|
||||
);
|
||||
});
|
||||
|
||||
test('Live Contacts and Space Missions go through the one setContextMode facade', async () => {
|
||||
for (const [choice, mode] of [['contacts', 'contacts'], ['space-missions', 'space-missions']]) {
|
||||
for (const [choice, mode] of [
|
||||
['contacts', 'contacts'],
|
||||
['space-missions', 'space-missions'],
|
||||
]) {
|
||||
const spy = missionSpy();
|
||||
const outcome = await runFirstRunChoice(choice, spy.deps);
|
||||
assert.equal(outcome.ok, true);
|
||||
@@ -445,8 +633,14 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
|
||||
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
|
||||
// post-launch, and the note must stay where the next editor will read it
|
||||
// rather than being re-discovered as a launcher bug.
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const table = module.slice(module.indexOf(' environmental: Object.freeze({'), module.indexOf(' explore:'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const table = module.slice(
|
||||
module.indexOf(' environmental: Object.freeze({'),
|
||||
module.indexOf(' explore:'),
|
||||
);
|
||||
assert.match(table, /KEY REQUIRED/);
|
||||
assert.match(table, /src\/loadingFeedback\.js/);
|
||||
assert.match(table, /LEDGERED post-launch/);
|
||||
@@ -461,7 +655,10 @@ test('every visitor gets the same tile — there is no degraded keyless variant'
|
||||
assert.equal(outcome.ok, true);
|
||||
assert.deepEqual(outcome.failedLayerIds, []);
|
||||
assert.deepEqual(spy.calls.layerIds, ['earthquakes', 'local-firms']);
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
module.slice(module.indexOf('export async function runFirstRunChoice')),
|
||||
/FIRMS_MAP_KEY|hasKey|keyless\s*\?/,
|
||||
@@ -476,16 +673,31 @@ test('a refused layer fails the mission by name, and a stalled flight never does
|
||||
assert.deepEqual(outcome.failedLayerIds, ['earthquakes']);
|
||||
|
||||
// The globe flight is framing. A cancelled or throwing flight is not a failure.
|
||||
const flightDown = missionSpy({ globe: () => { throw new Error('cancelled'); } });
|
||||
assert.equal((await runFirstRunChoice('environmental', flightDown.deps)).ok, true);
|
||||
const flightDown = missionSpy({
|
||||
globe: () => {
|
||||
throw new Error('cancelled');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await runFirstRunChoice('environmental', flightDown.deps)).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Explore manually touches nothing at all, and an unknown choice is inert', async () => {
|
||||
const spy = missionSpy();
|
||||
assert.equal((await runFirstRunChoice('explore', spy.deps)).ok, true);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
assert.equal((await runFirstRunChoice('nope', spy.deps)).ok, false);
|
||||
assert.deepEqual(spy.calls, { contextModes: [], layerIds: [], globeFlights: 0 });
|
||||
assert.deepEqual(spy.calls, {
|
||||
contextModes: [],
|
||||
layerIds: [],
|
||||
globeFlights: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed Context mission reports the layers the facade named', async () => {
|
||||
@@ -500,14 +712,22 @@ test('the fires/quakes tile name is switchable from one constant', () => {
|
||||
assert.equal(environmentalLabel('EARTH_WATCH').title, 'EARTH WATCH');
|
||||
assert.equal(environmentalLabel('ACTIVE_EVENTS').title, 'ACTIVE EVENTS');
|
||||
assert.equal(environmentalLabel('nonsense').title, 'ENVIRONMENTAL');
|
||||
assert.equal(environmentalLabel().title, environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title);
|
||||
assert.equal(
|
||||
environmentalLabel().title,
|
||||
environmentalLabel(ENVIRONMENTAL_LABEL_CHOICE).title,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Defaults interplay: what a mission is allowed to persist ─────────────────
|
||||
|
||||
test('no mission writes a preference the visitor did not choose by picking it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const code = module.slice(module.indexOf('export function shouldShowFirstRun'));
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const code = module.slice(
|
||||
module.indexOf('export function shouldShowFirstRun'),
|
||||
);
|
||||
|
||||
// Layer enables ARE durable in this app and a mission tile IS that choice, so
|
||||
// they run at the same origin a click on those rows uses.
|
||||
@@ -528,22 +748,45 @@ test('no mission writes a preference the visitor did not choose by picking it',
|
||||
'_setModels3dParams',
|
||||
'setFeather',
|
||||
]) {
|
||||
assert.doesNotMatch(code, new RegExp(forbidden), `a mission must never touch ${forbidden}`);
|
||||
assert.doesNotMatch(
|
||||
code,
|
||||
new RegExp(forbidden),
|
||||
`a mission must never touch ${forbidden}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The only durable panel write is the Context reveal, and only on the Context
|
||||
// missions — the globe missions open no panel at all.
|
||||
const panelWrites = code.match(/setPanelCollapsed/g) || [];
|
||||
assert.equal(panelWrites.length, 1, 'exactly one panel reveal, on the Context path');
|
||||
const contextPath = code.slice(code.indexOf('setContextMode: async (mode)'), code.indexOf('setLayerEnabled:'));
|
||||
assert.match(contextPath, /result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/);
|
||||
assert.equal(
|
||||
panelWrites.length,
|
||||
1,
|
||||
'exactly one panel reveal, on the Context path',
|
||||
);
|
||||
const contextPath = code.slice(
|
||||
code.indexOf('setContextMode: async (mode)'),
|
||||
code.indexOf('setLayerEnabled:'),
|
||||
);
|
||||
assert.match(
|
||||
contextPath,
|
||||
/result\?\.ok[\s\S]*?setPanelCollapsed\?\.\('global-context-panel', false, \{\s*explicit: true,?\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the decision table is written down where the next editor will read it', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
assert.match(module, /MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/);
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
module,
|
||||
/MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST/,
|
||||
);
|
||||
for (const row of ['TOUCHED, DURABLE', 'TOUCHED, SESSION', 'NOT TOUCHED']) {
|
||||
assert.ok(module.includes(row), `decision table is missing its "${row}" rows`);
|
||||
assert.ok(
|
||||
module.includes(row),
|
||||
`decision table is missing its "${row}" rows`,
|
||||
);
|
||||
}
|
||||
assert.match(module, /SHOW POLICY/);
|
||||
});
|
||||
@@ -551,56 +794,109 @@ test('the decision table is written down where the next editor will read it', ()
|
||||
// ── Markup, startup ordering, accessibility ─────────────────────────────────
|
||||
|
||||
test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
|
||||
const html = expandApplicationHtml(
|
||||
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||
);
|
||||
const startup = fs.readFileSync(
|
||||
new URL('./app/startupChrome.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
|
||||
assert.match(
|
||||
html,
|
||||
/id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/,
|
||||
);
|
||||
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
|
||||
assert.match(html, /data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
assert.match(
|
||||
html,
|
||||
/data-first-run-status[^>]*role="status"[^>]*aria-live="polite"/,
|
||||
);
|
||||
assert.match(html, /<input type="checkbox" data-first-run-suppress \/>/);
|
||||
assert.match(html, /<strong data-first-run-environmental-title>/);
|
||||
// Subcopy must name BOTH feeds the tile turns on — a tile that promised only
|
||||
// half of what it does is the defect this replaced. Only the VISIBLE <small>
|
||||
// text counts; the comment beside it naturally says the words too.
|
||||
const envTile = html.slice(html.indexOf('data-first-run-choice="environmental"'));
|
||||
const visible = envTile.slice(envTile.indexOf('<small>'), envTile.indexOf('</small>'));
|
||||
const envTile = html.slice(
|
||||
html.indexOf('data-first-run-choice="environmental"'),
|
||||
);
|
||||
const visible = envTile.slice(
|
||||
envTile.indexOf('<small>'),
|
||||
envTile.indexOf('</small>'),
|
||||
);
|
||||
assert.match(visible, /earthquakes/i);
|
||||
assert.match(visible, /fires?/i, 'the tile must promise the fires it enables');
|
||||
assert.match(
|
||||
visible,
|
||||
/fires?/i,
|
||||
'the tile must promise the fires it enables',
|
||||
);
|
||||
|
||||
// The card's one persuasive line is OWNER-AUTHORED and pinned verbatim,
|
||||
// unspaced em dash included. This is copy, not prose to be improved in a
|
||||
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
|
||||
assert.ok(
|
||||
html.includes('<p id="first-run-description">It feels like a forbidden cockpit'
|
||||
+ '—then you realize the sources are public and the data is real.</p>'),
|
||||
html.includes(
|
||||
'<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit' +
|
||||
'—then you realize the sources are public and the data is real.</p>',
|
||||
),
|
||||
'the owner-authored first-run line must ship exactly as written',
|
||||
);
|
||||
|
||||
// Menu order is the owner's, read straight off the markup.
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(order, ['contacts', 'space-missions', 'environmental', 'explore']);
|
||||
assert.doesNotMatch(html, /data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind');
|
||||
const order = [...html.matchAll(/data-first-run-choice="([a-z-]+)"/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
assert.deepEqual(order, [
|
||||
'contacts',
|
||||
'space-missions',
|
||||
'environmental',
|
||||
'explore',
|
||||
]);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/data-first-run-choice="infrastructure"/,
|
||||
'the removed tile must leave no markup behind',
|
||||
);
|
||||
|
||||
assert.match(startup, /styleManager\.initialRestorePromise/);
|
||||
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
|
||||
assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
|
||||
assert.ok(
|
||||
startup.indexOf("loadingScreen.classList.add('hidden')") <
|
||||
startup.indexOf(
|
||||
"loadingScreen.addEventListener('transitionend', revealFirstRun",
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
startup,
|
||||
/initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/,
|
||||
);
|
||||
|
||||
assert.match(css, /body\.ui-clean-view #first-run-launcher/);
|
||||
assert.match(css, /body\.recording-mode #first-run-launcher/);
|
||||
// Scoped, not a bare search: style.css has other reduced-motion blocks, and
|
||||
// matching one of THOSE would let the launcher's own opt-out be deleted.
|
||||
const reducedMotion = [...css.matchAll(/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g)]
|
||||
.map((match) => match[1]);
|
||||
const reducedMotion = [
|
||||
...css.matchAll(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
assert.ok(
|
||||
reducedMotion.some((block) => block.includes('#first-run-launcher') && block.includes('.first-run-choices')),
|
||||
reducedMotion.some(
|
||||
(block) =>
|
||||
block.includes('#first-run-launcher') &&
|
||||
block.includes('.first-run-choices'),
|
||||
),
|
||||
'the launcher must keep its OWN prefers-reduced-motion block, covering the card and the scrolling list',
|
||||
);
|
||||
// The card must be click-through until revealed and again while it fades out.
|
||||
const base = css.slice(css.indexOf('#first-run-launcher {'), css.indexOf('#first-run-launcher.visible'));
|
||||
const base = css.slice(
|
||||
css.indexOf('#first-run-launcher {'),
|
||||
css.indexOf('#first-run-launcher.visible'),
|
||||
);
|
||||
assert.match(base, /pointer-events: none/);
|
||||
assert.match(css, /#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/);
|
||||
assert.match(
|
||||
css,
|
||||
/#first-run-launcher\.visible \{[\s\S]*?pointer-events: auto/,
|
||||
);
|
||||
// The card is a flex column so its mission list can scroll on a short
|
||||
// viewport — and an AUTHOR `display` on this id outranks the UA's
|
||||
// `[hidden] { display: none }`, which would strand the card in the
|
||||
@@ -616,13 +912,22 @@ test('markup, startup ordering and accessibility remain pinned', () => {
|
||||
});
|
||||
|
||||
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
|
||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||
const module = fs.readFileSync(
|
||||
new URL('./firstRunExperience.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// aria-disabled, never the `disabled` property: disabling a focused button
|
||||
// drops the keyboard to <body> and strands the visitor outside the launcher.
|
||||
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
|
||||
assert.match(
|
||||
module,
|
||||
/button\.setAttribute\('aria-disabled', String\(next\)\)/,
|
||||
);
|
||||
assert.doesNotMatch(module, /button\.disabled = /);
|
||||
// Tab is confined to the launcher, and ESC always releases it.
|
||||
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||
const keyboard = fs.readFileSync(
|
||||
new URL('./ui/surfaceKeyboard.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(module, /keyboard\.activate\(\)/);
|
||||
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
|
||||
assert.match(keyboard, /event\.key !== 'Tab'/);
|
||||
@@ -633,7 +938,10 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
|
||||
});
|
||||
|
||||
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
|
||||
const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
|
||||
const ui = fs.readFileSync(
|
||||
new URL('./ui/panelPositionControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
|
||||
// now, so it was opening to offer controls for things already happening —
|
||||
// while competing with the mission card for the one first impression there is.
|
||||
@@ -644,7 +952,11 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
||||
);
|
||||
// The first-run default may only apply when NOTHING is stored: a visitor who
|
||||
// opened the rail keeps it open, and one who closed it keeps it closed.
|
||||
const block = ui.slice(ui.indexOf('stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))'));
|
||||
const block = ui.slice(
|
||||
ui.indexOf(
|
||||
'stored = localStorage.getItem(this._panelCollapseStorageKey(panelId))',
|
||||
),
|
||||
);
|
||||
const guard = block.slice(0, block.indexOf('panelEl.classList.toggle'));
|
||||
assert.match(guard, /if \(stored === '1'\) collapsed = true;/);
|
||||
assert.match(guard, /if \(stored === '0'\) collapsed = false;/);
|
||||
@@ -661,9 +973,17 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
|
||||
(tool) => tool.name !== 'set_cyber_sonar',
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, [
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
@@ -680,7 +1000,10 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
const instructions = fs.readFileSync(
|
||||
new URL('../server/providers/openai/instructions.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// ...and the mapping that makes them reachable by voice is one instruction
|
||||
// string, whose rollback is deleting that string. Anchored to a LIVE array
|
||||
@@ -691,23 +1014,39 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
/\n\s+'NAMED VIEWS are shorthand/,
|
||||
'the mission mapping must be an active instruction entry, not commented out',
|
||||
);
|
||||
const mapping = instructions.slice(instructions.indexOf('NAMED VIEWS are shorthand'));
|
||||
const mapping = instructions.slice(
|
||||
instructions.indexOf('NAMED VIEWS are shorthand'),
|
||||
);
|
||||
const paragraph = mapping.slice(0, mapping.indexOf("',\n"));
|
||||
for (const layerId of [
|
||||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
]) {
|
||||
assert.ok(paragraph.includes(layerId), `mapping must name the existing ${layerId} enum value`);
|
||||
assert.ok(
|
||||
paragraph.includes(layerId),
|
||||
`mapping must name the existing ${layerId} enum value`,
|
||||
);
|
||||
}
|
||||
assert.ok(paragraph.includes('zoom_to_globe'));
|
||||
assert.ok(paragraph.includes('set_layer_visibility'));
|
||||
});
|
||||
|
||||
test('every layer a mission drives is already in the shipped set_layer_visibility enum', () => {
|
||||
const tool = GEV_REALTIME_TOOLS.find(tool => tool.name === 'set_layer_visibility');
|
||||
const tool = GEV_REALTIME_TOOLS.find(
|
||||
(tool) => tool.name === 'set_layer_visibility',
|
||||
);
|
||||
const allowedLayers = tool.parameters.properties.layerId.enum;
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap((mission) => mission.layerIds || []);
|
||||
const missionLayerIds = Object.values(FIRST_RUN_MISSIONS).flatMap(
|
||||
(mission) => mission.layerIds || [],
|
||||
);
|
||||
assert.ok(missionLayerIds.length > 0);
|
||||
for (const layerId of missionLayerIds) {
|
||||
assert.ok(allowedLayers.includes(layerId), `${layerId} must already be an allowed enum value`);
|
||||
assert.ok(
|
||||
allowedLayers.includes(layerId),
|
||||
`${layerId} must already be an allowed enum value`,
|
||||
);
|
||||
}
|
||||
});
|
||||
+151
-15
@@ -27,7 +27,7 @@ import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import {
|
||||
hudSummaryMatchesProvenance,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
} from './hudSummaryResponse.js';
|
||||
import {
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -61,6 +63,110 @@ const HUD_COLORS = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode names shown for each style. Same English as STYLE_STATUS_LABELS in
|
||||
* ui/visualPresets.js, so the top-left mode and the dock's active style
|
||||
* translate alike.
|
||||
*/
|
||||
const MODE_LABELS = Object.freeze({
|
||||
normal: msg('NORMAL'),
|
||||
retro: msg('CRT'),
|
||||
surveillance: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
/** Region ids from _regionLabel() and the name shown for each. */
|
||||
const REGION_LABELS = Object.freeze({
|
||||
ARCTIC: msg('ARCTIC'),
|
||||
ANTARCTIC: msg('ANTARCTIC'),
|
||||
'NORTH AMERICA': msg('NORTH AMERICA'),
|
||||
'SOUTH AMERICA': msg('SOUTH AMERICA'),
|
||||
EUROPE: msg('EUROPE'),
|
||||
AFRICA: msg('AFRICA'),
|
||||
ASIA: msg('ASIA'),
|
||||
OCEANIA: msg('OCEANIA'),
|
||||
'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
|
||||
'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
|
||||
});
|
||||
|
||||
/** Feed-state ids from hudTelemetryProvenance() and the word shown for each. */
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
loading: msg('LOADING'),
|
||||
degraded: msg('DEGRADED'),
|
||||
stale: msg('STALE'),
|
||||
partial: msg('PARTIAL'),
|
||||
fallback: msg('FALLBACK'),
|
||||
unavailable: msg('UNAVAILABLE'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The summary's feed-state suffix in the active language, e.g.
|
||||
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
|
||||
* layer data; only this line translates them.
|
||||
*/
|
||||
function localizedProvenanceTag(provenance) {
|
||||
if (!provenance) return null;
|
||||
const state = FEED_STATE_LABELS[provenance.state]
|
||||
? t(FEED_STATE_LABELS[provenance.state])
|
||||
: provenance.state.toUpperCase();
|
||||
if (!provenance.names.length) return state;
|
||||
return t('{state} {layers}', {
|
||||
state,
|
||||
layers: provenance.names
|
||||
.map((name) => localizeLayerName(name).toUpperCase())
|
||||
.join('/'),
|
||||
});
|
||||
}
|
||||
|
||||
/** The name shown for a _viewBand() id. */
|
||||
function viewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'STREET':
|
||||
return t('STREET', { context: 'view band' });
|
||||
case 'CITY':
|
||||
return t('CITY', { context: 'view band' });
|
||||
case 'METRO':
|
||||
return t('METRO', { context: 'view band' });
|
||||
case 'REGIONAL':
|
||||
return t('REGIONAL', { context: 'view band' });
|
||||
default:
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
}
|
||||
}
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
function hudQuantity(value, unit, digits = 0) {
|
||||
const number = formatNumber(value, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return `${number}${unitLabel(unit).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */
|
||||
function hudCoordinate(value, positive, negative) {
|
||||
return `${hudQuantity(Math.abs(value), '', 2)}${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
/** The summary's locality line in the active language (see hudLocality.js). */
|
||||
const LOCALIZED_LOCALITY = Object.freeze({
|
||||
near: ({ poi, city, distKm }) =>
|
||||
t('NEAR {poi} ({city}) {distance}', {
|
||||
poi,
|
||||
city,
|
||||
distance: hudQuantity(Math.round(distKm), 'km'),
|
||||
}),
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
t('SECTOR {lat} {lon}', {
|
||||
lat: hudCoordinate(latDeg, t('N'), t('S')),
|
||||
lon: hudCoordinate(lonDeg, t('E'), t('W')),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||
|
||||
@@ -186,6 +292,7 @@ export class IntelHUD {
|
||||
this._el = document.getElementById('intel-hud');
|
||||
if (!this._el) return;
|
||||
|
||||
// i18n-ignore: sensor readout codes (MGRS, GSD, NIIRS, ONA, BAND…) and a classification banner; translated parts use t()
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
@@ -200,7 +307,7 @@ export class IntelHUD {
|
||||
<div class="hud-content">
|
||||
<div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div>
|
||||
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
|
||||
<div class="hud-mode" id="hud-mode">NORMAL</div>
|
||||
<div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div>
|
||||
<div class="hud-summary-wrap">
|
||||
<div class="hud-summary-label">${t('SUMMARY')}</div>
|
||||
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
|
||||
@@ -228,7 +335,7 @@ export class IntelHUD {
|
||||
<div class="hud-content" style="text-align:right">
|
||||
<div id="hud-gsd">GSD: --m NIIRS: --</div>
|
||||
<div id="hud-alt">ALT: --m SUN: --° EL</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">AIS: --</div>
|
||||
<div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
@@ -356,9 +463,11 @@ export class IntelHUD {
|
||||
const formatted = this._formatMGRS(mgrsStr);
|
||||
mgrsLabel = formatted;
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (el) el.textContent = `MGRS: ${formatted}`;
|
||||
} catch {
|
||||
const el = document.getElementById('hud-mgrs');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (el) el.textContent = 'MGRS: ---';
|
||||
}
|
||||
|
||||
@@ -367,6 +476,7 @@ export class IntelHUD {
|
||||
if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`;
|
||||
const bottomEl = document.getElementById('hud-bottom-line');
|
||||
if (bottomEl) {
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`;
|
||||
}
|
||||
|
||||
@@ -382,6 +492,7 @@ export class IntelHUD {
|
||||
);
|
||||
const gsdEl = document.getElementById('hud-gsd');
|
||||
if (gsdEl)
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`;
|
||||
|
||||
// Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw
|
||||
@@ -394,6 +505,7 @@ export class IntelHUD {
|
||||
const altMslM = ellipsoidalToMslDisplayM(altM, geoidN);
|
||||
const sunEl = this._estimateSunElevation(latDeg, lonDeg);
|
||||
if (altEl)
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||
|
||||
// Collection timestamp
|
||||
@@ -403,6 +515,7 @@ export class IntelHUD {
|
||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
collEl.textContent = `COLL: ${h}:${m}:${s}Z`;
|
||||
}
|
||||
|
||||
@@ -411,6 +524,7 @@ export class IntelHUD {
|
||||
const pitchDeg = Cesium.Math.toDegrees(camera.pitch);
|
||||
const ona = Math.max(0, 90 + pitchDeg);
|
||||
const onaEl = document.getElementById('hud-ona');
|
||||
// i18n-ignore: sensor readout codes, same in every language
|
||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||
|
||||
// `altM` stays the raw ellipsoidal camera height the sensor model reads
|
||||
@@ -638,7 +752,7 @@ export class IntelHUD {
|
||||
if (!m) return t('Awaiting telemetry...');
|
||||
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
const modeLabel = modeEl?.textContent || 'NORMAL';
|
||||
const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal);
|
||||
const region = this._regionLabel(m.latDeg, m.lonDeg);
|
||||
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
|
||||
const band = this._viewBand(m.altM);
|
||||
@@ -650,20 +764,41 @@ export class IntelHUD {
|
||||
// together, so they must never disagree. The view band above deliberately
|
||||
// keeps the ellipsoidal height: its thresholds were tuned against it.
|
||||
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
|
||||
// Display only: the AI summary request carries its own context, so this
|
||||
// line is built in the active language.
|
||||
const altTag =
|
||||
altDisplayM >= 1000
|
||||
? `${(altDisplayM / 1000).toFixed(1)}KM`
|
||||
: `${Math.round(altDisplayM)}M`;
|
||||
? hudQuantity(altDisplayM / 1000, 'km', 1)
|
||||
: hudQuantity(Math.round(altDisplayM), 'm');
|
||||
const winTag = window
|
||||
? `${Math.max(1, Math.round(window.widthKm))}x${Math.max(1, Math.round(window.heightKm))}KM`
|
||||
: 'N/A';
|
||||
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
|
||||
: t('N/A');
|
||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
||||
const localityTag = composeLocalityTag(
|
||||
nearest,
|
||||
m.latDeg,
|
||||
m.lonDeg,
|
||||
LOCALIZED_LOCALITY,
|
||||
);
|
||||
|
||||
const provenance = hudTelemetryProvenanceTag(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
const provenance = localizedProvenanceTag(
|
||||
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
|
||||
);
|
||||
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
|
||||
const line = t(
|
||||
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
|
||||
{
|
||||
mode: modeLabel,
|
||||
band: viewBandLabel(band),
|
||||
locality: localityTag,
|
||||
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
|
||||
alt: altTag,
|
||||
window: winTag,
|
||||
sun: formatNumber(Math.round(m.sunEl)),
|
||||
ona: formatNumber(Math.round(m.ona)),
|
||||
zone: localTag,
|
||||
},
|
||||
);
|
||||
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
}
|
||||
|
||||
@@ -819,8 +954,9 @@ export class IntelHUD {
|
||||
// Update mode label
|
||||
const modeEl = document.getElementById('hud-mode');
|
||||
if (modeEl) {
|
||||
const modeNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
|
||||
modeEl.textContent = modeNames[styleName] || styleName.toUpperCase();
|
||||
modeEl.textContent = MODE_LABELS[styleName]
|
||||
? t(MODE_LABELS[styleName])
|
||||
: styleName.toUpperCase();
|
||||
}
|
||||
// Update color scheme
|
||||
const colors = HUD_COLORS[styleName] || HUD_COLORS._default;
|
||||
|
||||
+25
-3
@@ -23,6 +23,17 @@
|
||||
*/
|
||||
export const NEAR_POI_MAX_KM = 150;
|
||||
|
||||
/**
|
||||
* The English locality line. The HUD passes its own formatter so the page
|
||||
* shows it in the active language; this default stays i18n-free.
|
||||
*/
|
||||
const ENGLISH_LOCALITY = Object.freeze({
|
||||
near: ({ poi, city, distKm }) =>
|
||||
`NEAR ${poi} (${city}) ${Math.round(distKm)}KM`,
|
||||
sector: ({ latDeg, lonDeg }) =>
|
||||
`SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Format one hemisphere-tagged coordinate, e.g. `21.33N` / `157.80W`.
|
||||
* @param {number} value Signed decimal degrees.
|
||||
@@ -42,13 +53,24 @@ function coordinateTag(value, positive, negative) {
|
||||
* catalogue is empty.
|
||||
* @param {number} latDeg Camera latitude in decimal degrees.
|
||||
* @param {number} lonDeg Camera longitude in decimal degrees.
|
||||
* @param {{near: Function, sector: Function}} [format] Builds the line from
|
||||
* `{ poi, city, distKm }` (names upper-cased) or `{ latDeg, lonDeg }`.
|
||||
* @returns {string} `NEAR <POI> (<CITY>) <N>KM` within the bound (inclusive),
|
||||
* otherwise `SECTOR <lat> <lon>`.
|
||||
*/
|
||||
export function composeLocalityTag(nearest, latDeg, lonDeg) {
|
||||
export function composeLocalityTag(
|
||||
nearest,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
format = ENGLISH_LOCALITY,
|
||||
) {
|
||||
const distKm = Number(nearest?.distKm);
|
||||
if (nearest && Number.isFinite(distKm) && distKm <= NEAR_POI_MAX_KM) {
|
||||
return `NEAR ${String(nearest.poi).toUpperCase()} (${String(nearest.city).toUpperCase()}) ${Math.round(distKm)}KM`;
|
||||
return format.near({
|
||||
poi: String(nearest.poi).toUpperCase(),
|
||||
city: String(nearest.city).toUpperCase(),
|
||||
distKm,
|
||||
});
|
||||
}
|
||||
return `SECTOR ${coordinateTag(latDeg, 'N', 'S')} ${coordinateTag(lonDeg, 'E', 'W')}`;
|
||||
return format.sector({ latDeg, lonDeg });
|
||||
}
|
||||
@@ -17,7 +17,7 @@ test('the NEAR bound is metro scale, not continental', () => {
|
||||
|
||||
test('a landmark under the camera reads NEAR', () => {
|
||||
assert.equal(
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.4230),
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8267, -122.423),
|
||||
'NEAR ALCATRAZ ISLAND (SAN FRANCISCO) 2KM',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -40,20 +40,42 @@ test('the field failures now fall through to the SECTOR readout', () => {
|
||||
});
|
||||
|
||||
test('the boundary is pinned on both sides, inclusive at the bound', () => {
|
||||
const at = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM }, 40, -78);
|
||||
assert.match(at, /^NEAR LINCOLN MEMORIAL/, 'exactly at the bound still reads NEAR');
|
||||
const at = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(
|
||||
at,
|
||||
/^NEAR LINCOLN MEMORIAL/,
|
||||
'exactly at the bound still reads NEAR',
|
||||
);
|
||||
|
||||
const just_under = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 }, 40, -78);
|
||||
const just_under = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM - 0.1 },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(just_under, /^NEAR LINCOLN MEMORIAL/);
|
||||
|
||||
const just_over = composeLocalityTag({ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 }, 40, -78);
|
||||
const just_over = composeLocalityTag(
|
||||
{ ...LINCOLN, distKm: NEAR_POI_MAX_KM + 0.1 },
|
||||
40,
|
||||
-78,
|
||||
);
|
||||
assert.match(just_over, /^SECTOR /, 'one step past the bound falls through');
|
||||
});
|
||||
|
||||
test('southern and western hemispheres carry the right suffixes', () => {
|
||||
// Rio and Honolulu — the two the fallback already handled correctly in the field.
|
||||
assert.equal(composeLocalityTag(null, -22.9068, -43.1729), 'SECTOR 22.91S 43.17W');
|
||||
assert.equal(composeLocalityTag(null, 21.3069, -157.8583), 'SECTOR 21.31N 157.86W');
|
||||
assert.equal(
|
||||
composeLocalityTag(null, -22.9068, -43.1729),
|
||||
'SECTOR 22.91S 43.17W',
|
||||
);
|
||||
assert.equal(
|
||||
composeLocalityTag(null, 21.3069, -157.8583),
|
||||
'SECTOR 21.31N 157.86W',
|
||||
);
|
||||
});
|
||||
|
||||
// The tests above all pass against a hud.js that still computes the tag inline —
|
||||
@@ -72,7 +94,9 @@ test('hud.js actually composes its summary through this helper', () => {
|
||||
'hud.js must import composeLocalityTag from ./hudLocality.js',
|
||||
);
|
||||
assert.equal(
|
||||
has(/const localityTag = composeLocalityTag\(nearest, m\.latDeg, m\.lonDeg\);/),
|
||||
has(
|
||||
/const localityTag = composeLocalityTag\(\s*nearest,\s*m\.latDeg,\s*m\.lonDeg,\s*LOCALIZED_LOCALITY,?\s*\);/,
|
||||
),
|
||||
true,
|
||||
'_composeSummary must build its locality tag through composeLocalityTag()',
|
||||
);
|
||||
@@ -91,6 +115,24 @@ test('hud.js actually composes its summary through this helper', () => {
|
||||
test('a missing or malformed nearest POI never crashes the summary', () => {
|
||||
assert.match(composeLocalityTag(null, 0, 0), /^SECTOR /);
|
||||
assert.match(composeLocalityTag(undefined, 0, 0), /^SECTOR /);
|
||||
assert.match(composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10), /^SECTOR /);
|
||||
assert.match(
|
||||
composeLocalityTag({ ...LINCOLN, distKm: NaN }, 10, 10),
|
||||
/^SECTOR /,
|
||||
);
|
||||
assert.match(composeLocalityTag({ ...LINCOLN }, 10, 10), /^SECTOR /);
|
||||
});
|
||||
|
||||
test('a formatter builds the line from the same parts', () => {
|
||||
const format = {
|
||||
near: ({ poi, city, distKm }) => `near:${poi}|${city}|${distKm}`,
|
||||
sector: ({ latDeg, lonDeg }) => `sector:${latDeg}|${lonDeg}`,
|
||||
};
|
||||
assert.equal(
|
||||
composeLocalityTag({ ...ALCATRAZ, distKm: 2.4 }, 37.8, -122.4, format),
|
||||
'near:ALCATRAZ ISLAND|SAN FRANCISCO|2.4',
|
||||
);
|
||||
assert.equal(
|
||||
composeLocalityTag(null, 21.3, -157.8, format),
|
||||
'sector:21.3|-157.8',
|
||||
);
|
||||
});
|
||||
@@ -83,12 +83,14 @@ export function hudSummaryLayerContext(layers = [], options) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic telemetry suffix when an enabled feed is not nominal.
|
||||
* The feed state and up to two layer names for the HUD telemetry suffix when
|
||||
* an enabled feed is not nominal. Values stay English (`state` is a feed-state
|
||||
* id, `names` are `layer.name`); the HUD translates them for display.
|
||||
* @param {Array<object>} [layers]
|
||||
* @param {{ now?: number }} [options]
|
||||
* @returns {string|null}
|
||||
* @returns {{ state: string, names: string[] }|null}
|
||||
*/
|
||||
export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||
export function hudTelemetryProvenance(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||
@@ -96,9 +98,9 @@ export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
|
||||
.map((s) => String(s.name || s.id || 'LAYER'))
|
||||
.slice(0, 2);
|
||||
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
||||
return { state: envelope.overall, names };
|
||||
}
|
||||
|
||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||
|
||||
+111
-39
@@ -5,7 +5,7 @@ import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
hudTelemetryProvenance,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
} from './hudSummaryResponse.js';
|
||||
@@ -30,7 +30,10 @@ function installOpenAiRoutes() {
|
||||
return routes;
|
||||
}
|
||||
|
||||
function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) {
|
||||
function invokeRoute(
|
||||
handler,
|
||||
{ method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = new Map();
|
||||
const req = {
|
||||
@@ -71,33 +74,54 @@ test('builds an HTTP-success capability response only for a blank key', () => {
|
||||
test('recognizes only the exact deliberate no-key fallback response', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(200, UNCONFIGURED_PAYLOAD), true);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
error: 'provider failed',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
summary: 'Unexpected provider output',
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
configured: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
unexpected: true,
|
||||
}), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
error: 'provider failed',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
summary: 'Unexpected provider output',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
configured: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
...UNCONFIGURED_PAYLOAD,
|
||||
unexpected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not hide real provider and HTTP failures', () => {
|
||||
assert.equal(isHudSummaryUnconfigured(502, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(502, {
|
||||
code: HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isHudSummaryUnconfigured(503, UNCONFIGURED_PAYLOAD), false);
|
||||
assert.equal(isHudSummaryUnconfigured(200, { error: 'provider failed' }), false);
|
||||
assert.equal(
|
||||
isHudSummaryUnconfigured(200, { error: 'provider failed' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the installed keyless HUD route stays successful after the voice quota is exhausted', async () => {
|
||||
@@ -121,14 +145,18 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const response = await invokeRoute(hud, { method: 'POST' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(
|
||||
response.headers['content-type'],
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.deepEqual(response.body, UNCONFIGURED_PAYLOAD);
|
||||
}
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = previousKey;
|
||||
if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
if (previousLimit === undefined)
|
||||
delete process.env.GEV_RATELIMIT_OPENAI_PER_MIN;
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
@@ -140,7 +168,12 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
|
||||
stats: {
|
||||
stale: true,
|
||||
count: 12,
|
||||
lastUpdate: now - 240_000,
|
||||
source: 'OpenSky Network',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
@@ -153,27 +186,66 @@ test('HUD summary context and telemetry tag carry chip-identical feed-state', ()
|
||||
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||
assert.equal(context.feedProvenance.overall, 'stale');
|
||||
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
||||
assert.equal(hudTelemetryProvenanceTag([{
|
||||
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
||||
}], { now }), null);
|
||||
assert.deepEqual(hudTelemetryProvenance(layers, { now }), {
|
||||
state: 'stale',
|
||||
names: ['Live Flights'],
|
||||
});
|
||||
assert.equal(
|
||||
hudTelemetryProvenance(
|
||||
[
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { count: 4, lastUpdate: now },
|
||||
},
|
||||
],
|
||||
{ now },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/five words MUST include that feedState token/,
|
||||
);
|
||||
assert.match(
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
/STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/,
|
||||
);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||
});
|
||||
|
||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
|
||||
const local = readFileSync(
|
||||
new URL('../server/providers/openai/hud-summary.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||
});
|
||||
|
||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
|
||||
const { hudSummaryMatchesProvenance } =
|
||||
await import('./hudSummaryResponse.js');
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin stale flights over downtown', {
|
||||
overall: 'stale',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hudSummaryMatchesProvenance('Austin flights operating normally today', {
|
||||
overall: 'nominal',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,11 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkCatalog,
|
||||
extractMessages,
|
||||
findUnmarked,
|
||||
readCatalog,
|
||||
shippedLocales,
|
||||
unmarkedMarkup,
|
||||
unmarkedScript,
|
||||
} from '../../scripts/i18n.mjs';
|
||||
import { LOCALES } from './locale.js';
|
||||
|
||||
@@ -32,6 +35,13 @@ for (const locale of shippedLocales()) {
|
||||
});
|
||||
}
|
||||
|
||||
test('every UI string is marked for translation or kept on purpose (npm run i18n)', () => {
|
||||
assert.deepEqual(
|
||||
findUnmarked().map(({ where, text }) => `${where} ${text}`),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
|
||||
const fixture = new Map([
|
||||
['Saved {name}', { plural: false }],
|
||||
@@ -55,3 +65,74 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', ()
|
||||
assert.deepEqual(result.stale, ['Removed']);
|
||||
assert.equal(result.errors.length, 2);
|
||||
});
|
||||
|
||||
test('checkCatalog reports a translation left in English unless it is allowed', () => {
|
||||
const fixture = new Map([
|
||||
['Bloom', { plural: false }],
|
||||
['HDG', { plural: false }],
|
||||
['voice|READY', { plural: false }],
|
||||
['{state} · {source}', { plural: false }],
|
||||
]);
|
||||
const catalog = {
|
||||
Bloom: 'Bloom',
|
||||
HDG: 'HDG',
|
||||
'voice|READY': 'READY',
|
||||
'{state} · {source}': '{state} · {source}',
|
||||
};
|
||||
const errors = (allowed) =>
|
||||
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
|
||||
assert.deepEqual(
|
||||
errors([]).map((error) => error.split(' is still English')[0]),
|
||||
['"Bloom"', '"HDG"', '"voice|READY"'],
|
||||
);
|
||||
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
|
||||
});
|
||||
|
||||
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
|
||||
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
|
||||
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
|
||||
assert.deepEqual(texts('<span data-i18n>Close</span>'), []);
|
||||
assert.deepEqual(texts('<button title="Pin it">x</button>'), [
|
||||
'title="Pin it"',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts('<button title="Pin it" data-i18n-title>x</button>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
texts('<select translate="no"><option>0.9 dB</option></select>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts('<b data-i18n-runtime><i>OFF</i></b>'), []);
|
||||
assert.deepEqual(
|
||||
texts('<span class="material-symbols-outlined">public</span>'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts('<!-- Note --><span>— 42 —</span>'), []);
|
||||
});
|
||||
|
||||
test('unmarkedScript finds literals that reach the page without t/tn/msg', () => {
|
||||
const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text);
|
||||
assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']);
|
||||
assert.deepEqual(texts("el.textContent = t('Saved');"), []);
|
||||
assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [
|
||||
'On air',
|
||||
'Off air',
|
||||
]);
|
||||
assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [
|
||||
'Open',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
texts("const row = { label: msg('Wind'), id: 'wind' };"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']);
|
||||
assert.deepEqual(texts("el.textContent = labelFor('flights');"), []);
|
||||
assert.deepEqual(texts("el.className = 'is-active';"), []);
|
||||
assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []);
|
||||
assert.deepEqual(texts("// el.textContent = 'Old copy';"), []);
|
||||
assert.deepEqual(
|
||||
texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -130,6 +130,12 @@ test('tn() picks English and Russian plural forms', () => {
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
||||
});
|
||||
|
||||
test('t() context separates identical English with different meanings', () => {
|
||||
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
|
||||
setLocale('ru');
|
||||
assert.notEqual(t('CLEAR', { context: 'action' }), t('CLEAR'));
|
||||
});
|
||||
|
||||
test('msg() returns its text unchanged', () => {
|
||||
assert.equal(msg('Live Flights'), 'Live Flights');
|
||||
});
|
||||
@@ -182,6 +188,31 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
|
||||
assert.equal(unknown.textContent, 'No such message');
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
|
||||
const calls = [];
|
||||
const location = {
|
||||
href: 'https://gev.local/?x=1#scene=a',
|
||||
assign: (url) => calls.push(['assign', url]),
|
||||
reload: () => calls.push(['reload']),
|
||||
};
|
||||
const history = {
|
||||
replaceState: (_s, _t, url) => calls.push(['replace', url]),
|
||||
};
|
||||
const storage = {
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
persistLocaleAndReload('ru', { location, history, storage }),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['replace', 'https://gev.local/?x=1&lang=ru#scene=a'],
|
||||
['reload'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => {
|
||||
const storage = memoryStorage();
|
||||
const calls = [];
|
||||
|
||||
+48
-5
@@ -3,6 +3,7 @@
|
||||
// t('Save keys') → 'Сохранить ключи'
|
||||
// t('Cleared {name}', { name }) → named interpolation
|
||||
// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
|
||||
// t('CLEAR', { context: 'action' }) → same English, own entry
|
||||
// msg('Live Flights') → marks a string kept in data
|
||||
// for a later t(); returns it as is
|
||||
//
|
||||
@@ -76,11 +77,13 @@ function lookup(text) {
|
||||
/**
|
||||
* Translate English source text into the active locale.
|
||||
* @param {string} text English text, e.g. 'Hold Space to talk'
|
||||
* @param {object} [values] Named interpolation values
|
||||
* @param {object} [values] Named interpolation values. `context` tells
|
||||
* apart identical English with different meanings: t('CLEAR', { context:
|
||||
* 'action' }) is the catalog entry "action|CLEAR"; English is unchanged.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(text, values) {
|
||||
const entry = lookup(text);
|
||||
const entry = lookup(values?.context ? `${values.context}|${text}` : text);
|
||||
return interpolate(typeof entry === 'string' ? entry : text, values);
|
||||
}
|
||||
|
||||
@@ -106,6 +109,18 @@ export function tn(count, one, other, values) {
|
||||
return interpolate(pattern, { ...values, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate text that may be ours or may come from a provider: a catalog
|
||||
* entry is used when there is one, anything else shows as given, without a
|
||||
* missing-translation warning.
|
||||
*/
|
||||
export function translateKnown(text) {
|
||||
const catalog = CATALOGS[currentLocale];
|
||||
if (!catalog || typeof text !== 'string') return text;
|
||||
const entry = catalog[text];
|
||||
return typeof entry === 'string' ? entry : text;
|
||||
}
|
||||
|
||||
/** Mark English text that is stored in data and translated later by t(). */
|
||||
export function msg(text) {
|
||||
return text;
|
||||
@@ -149,6 +164,32 @@ export function formatDate(value, options) {
|
||||
return cachedFormatter('date', Intl.DateTimeFormat, options).format(value);
|
||||
}
|
||||
|
||||
const UTC_DAY_MONTH = Object.freeze({
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The UTC day and month of a time, for "MM-DD HH:MM UTC" readouts: "10-13" in
|
||||
* English (as before), "13.10" in Russian, "13/10" in Spanish, never read as a
|
||||
* range. Takes an ISO string (its date part is used as written) or a time.
|
||||
* @param {string|number|Date} value
|
||||
* @returns {string} '' when the value is not a time.
|
||||
*/
|
||||
export function formatUtcDayMonth(value) {
|
||||
const written =
|
||||
typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
||||
const time = written
|
||||
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
|
||||
: new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return '';
|
||||
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
|
||||
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
|
||||
time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
||||
@@ -209,7 +250,7 @@ export function applyDocumentTranslations(doc) {
|
||||
/**
|
||||
* Persist a locale choice, then reload preserving window.location.hash exactly
|
||||
* and stripping ?lang from the search string, so the override is one-shot.
|
||||
* Storage is best-effort: where it is blocked the choice will not survive.
|
||||
* Where storage is blocked, ?lang carries the choice instead.
|
||||
* @returns {boolean} true when a reload was triggered.
|
||||
*/
|
||||
export function persistLocaleAndReload(
|
||||
@@ -221,12 +262,14 @@ export function persistLocaleAndReload(
|
||||
} = {},
|
||||
) {
|
||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||
writeStoredLocale(normalized, storage);
|
||||
const stored = writeStoredLocale(normalized, storage);
|
||||
if (!location || typeof location.assign !== 'function') return false;
|
||||
let target = location.href;
|
||||
try {
|
||||
// Without storage the choice only survives in the URL, so keep it there.
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete('lang');
|
||||
if (stored) url.searchParams.delete('lang');
|
||||
else url.searchParams.set('lang', normalized);
|
||||
target = url.href;
|
||||
} catch {
|
||||
// Keep the current href.
|
||||
|
||||
+1200
-76
File diff suppressed because it is too large.
Load diff
+1222
-77
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"es": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"ASIA",
|
||||
"AUTO",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Blues",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"ENE",
|
||||
"ERROR",
|
||||
"ESE",
|
||||
"Error: {message}",
|
||||
"FLIR",
|
||||
"FLT",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Global",
|
||||
"Global · 60°S–60°N",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"POS: {time}Z",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"R&B",
|
||||
"RADIO",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SECTOR {lat} {lon}",
|
||||
"SIG",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STD",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"VISUAL",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"GEO",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NAV",
|
||||
"NVG",
|
||||
"R&B",
|
||||
"REC",
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
]
|
||||
}
|
||||
+26
-17
@@ -128,12 +128,17 @@ function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||
state.textContent =
|
||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||
mode === 'oauth'
|
||||
? t('VOICE AUTH · CHATGPT OAUTH')
|
||||
: t('VOICE AUTH · API KEY');
|
||||
toggle.textContent =
|
||||
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
||||
toggle.title =
|
||||
mode === 'oauth'
|
||||
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
||||
: t(
|
||||
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
||||
);
|
||||
// Keep the control focusable while the delegated handler blocks repeats.
|
||||
toggle.setAttribute('aria-disabled', String(busy));
|
||||
}
|
||||
@@ -153,21 +158,22 @@ function buildRow(documentRef, key) {
|
||||
led.className = 'key-setup-led';
|
||||
led.setAttribute('aria-hidden', 'true');
|
||||
const title = documentRef.createElement('strong');
|
||||
title.textContent = key.title;
|
||||
title.textContent = key.title; // i18n-ignore: provider product name
|
||||
const tier = documentRef.createElement('span');
|
||||
tier.className = 'key-setup-tier';
|
||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
||||
tier.title =
|
||||
key.tier === 'metered'
|
||||
? 'Metered — a billing-enabled account'
|
||||
: 'Free key — register, paste, done';
|
||||
? t('Metered — a billing-enabled account')
|
||||
: t('Free key — register, paste, done');
|
||||
head.append(led, title, tier);
|
||||
if (key.clientExposed) {
|
||||
const exposed = documentRef.createElement('span');
|
||||
exposed.className = 'key-setup-exposed';
|
||||
exposed.textContent = 'browser-side';
|
||||
exposed.title =
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)';
|
||||
exposed.textContent = t('browser-side');
|
||||
exposed.title = t(
|
||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
||||
);
|
||||
head.append(exposed);
|
||||
}
|
||||
if (external) {
|
||||
@@ -175,9 +181,10 @@ function buildRow(documentRef, key) {
|
||||
// facts this panel reports, never values it rewrites or deletes.
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = 'key-setup-external';
|
||||
badge.textContent = 'configured externally';
|
||||
badge.title =
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set';
|
||||
badge.textContent = t('configured externally');
|
||||
badge.title = t(
|
||||
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
||||
);
|
||||
head.append(badge);
|
||||
}
|
||||
const get = documentRef.createElement('a');
|
||||
@@ -185,7 +192,7 @@ function buildRow(documentRef, key) {
|
||||
get.href = key.getUrl;
|
||||
get.target = '_blank';
|
||||
get.rel = 'noopener noreferrer';
|
||||
get.textContent = key.set ? 'MANAGE ↗' : 'GET KEY ↗';
|
||||
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
||||
head.append(get);
|
||||
|
||||
const unlocks = documentRef.createElement('p');
|
||||
@@ -206,8 +213,8 @@ function buildRow(documentRef, key) {
|
||||
input.dataset.envVar = envVar;
|
||||
input.setAttribute('aria-label', envVar);
|
||||
input.placeholder = key.set
|
||||
? `${envVar} saved — paste to replace`
|
||||
: `paste ${envVar}`;
|
||||
? t('{envVar} saved — paste to replace', { envVar })
|
||||
: t('paste {envVar}', { envVar });
|
||||
fields.append(input);
|
||||
}
|
||||
if (key.managed === 'file') {
|
||||
@@ -328,7 +335,9 @@ export async function initKeySetup({
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
||||
chip.title = t('Project keys: {status}', {
|
||||
status: keySetupChipLabel(status),
|
||||
});
|
||||
chip.setAttribute('aria-label', chip.title);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
|
||||
+10
-32
@@ -39,7 +39,7 @@ const msg = (text) => text;
|
||||
export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'google-maps',
|
||||
title: 'GOOGLE MAPS',
|
||||
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
|
||||
unlocks: msg('The photorealistic 3D planet, place search + Street View'),
|
||||
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
@@ -48,7 +48,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'google-maps-server',
|
||||
title: 'GOOGLE MAPS — SERVER',
|
||||
title: 'GOOGLE MAPS — SERVER', // i18n-ignore: provider product name; row is hidden
|
||||
unlocks: msg(
|
||||
'Places context + Street View fallback; optional separate key',
|
||||
),
|
||||
@@ -60,7 +60,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'openai',
|
||||
title: 'OPENAI',
|
||||
title: 'OPENAI', // i18n-ignore: provider product name
|
||||
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
|
||||
getUrl: 'https://platform.openai.com/api-keys',
|
||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||
@@ -68,7 +68,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'aisstream',
|
||||
title: 'AISSTREAM',
|
||||
title: 'AISSTREAM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live ships, worldwide'),
|
||||
getUrl: 'https://aisstream.io',
|
||||
envVars: Object.freeze(['AISSTREAM_API_KEY']),
|
||||
@@ -76,7 +76,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
title: 'NASA FIRMS', // i18n-ignore: provider product name
|
||||
unlocks: msg('Live active-fire detections'),
|
||||
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
|
||||
envVars: Object.freeze(['FIRMS_MAP_KEY']),
|
||||
@@ -84,7 +84,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'tomtom',
|
||||
title: 'TOMTOM',
|
||||
title: 'TOMTOM', // i18n-ignore: provider product name
|
||||
unlocks: msg('Real live traffic (keyless runs a simulation)'),
|
||||
getUrl: 'https://my.tomtom.com/keys',
|
||||
envVars: Object.freeze(['TOMTOM_API_KEY']),
|
||||
@@ -92,7 +92,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'cesium-ion',
|
||||
title: 'CESIUM ION',
|
||||
title: 'CESIUM ION', // i18n-ignore: provider product name
|
||||
unlocks: msg('Bing imagery map stacks + world terrain'),
|
||||
getUrl: 'https://ion.cesium.com/tokens',
|
||||
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
|
||||
@@ -101,7 +101,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'opensky',
|
||||
title: 'OPENSKY',
|
||||
title: 'OPENSKY', // i18n-ignore: provider product name
|
||||
unlocks: msg('More flight-polling credits (anonymous works without)'),
|
||||
getUrl: 'https://opensky-network.org',
|
||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||
@@ -109,7 +109,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'mapillary',
|
||||
title: 'MAPILLARY',
|
||||
title: 'MAPILLARY', // i18n-ignore: provider product name
|
||||
unlocks: msg(
|
||||
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||
),
|
||||
@@ -120,7 +120,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'launch-library',
|
||||
title: 'LAUNCH LIBRARY',
|
||||
title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name
|
||||
unlocks: msg('Higher space-missions request allowance'),
|
||||
getUrl: 'https://thespacedevs.com',
|
||||
envVars: Object.freeze(['LL2_API_TOKEN']),
|
||||
@@ -394,28 +394,6 @@ export function keySetupWithAlternatives(requirement, alternatives = []) {
|
||||
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative sources plus the registry name of the key each would use, as
|
||||
* machine values for presentation layers that localize the guidance sentence
|
||||
* themselves (this module also runs in node-side key-setup builds, which stay
|
||||
* English).
|
||||
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
|
||||
* @returns {Array<{label: string, name: string|null}>}
|
||||
*/
|
||||
export function keySetupAlternativeKeyNames(alternatives = []) {
|
||||
return alternatives.map(({ label, requiresKeyId }) => {
|
||||
const entry = KEY_SETUP_KEYS.find(
|
||||
(candidate) => candidate.id === requiresKeyId,
|
||||
);
|
||||
const name = entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
return { label, name };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a live provider value belongs to a source outside the store
|
||||
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
|
||||
|
||||
+33
-19
@@ -18,6 +18,7 @@ import {
|
||||
alprCreditMarkup,
|
||||
} from './model.js';
|
||||
import { createAlprPresentation } from './presentation.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Own one layer's requests, records, display and viewer subscriptions.
|
||||
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: source.label || 'Mapped camera locations',
|
||||
// English in layer state; the layer row translates it.
|
||||
source: source.label || msg('Mapped camera locations'),
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
chips: [
|
||||
{
|
||||
id: 'find-camera',
|
||||
label: 'SHOW NEAREST',
|
||||
label: t('SHOW NEAREST'),
|
||||
title: state.viewer?.trackedEntity
|
||||
? 'Stop following the current object before navigating to a camera'
|
||||
: 'Move to the nearest loaded camera and show its details',
|
||||
? t(
|
||||
'Stop following the current object before navigating to a camera',
|
||||
)
|
||||
: t('Move to the nearest loaded camera and show its details'),
|
||||
disabled:
|
||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||
onClick: focusNearest,
|
||||
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
label: 'Camera badges',
|
||||
label: t('Camera badges'),
|
||||
color: ALPR_COLOR,
|
||||
count,
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel:
|
||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
||||
Number.isInteger(state.onScreen)
|
||||
? ` · ${state.onScreen} on screen`
|
||||
: ''
|
||||
}`
|
||||
? Number.isInteger(state.onScreen)
|
||||
? tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
{ onScreen: state.onScreen },
|
||||
)
|
||||
: tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby',
|
||||
'{count} nearby',
|
||||
)
|
||||
: '',
|
||||
noCoverage: Boolean(state.noCoverage),
|
||||
lastUpdate: state.lastUpdate,
|
||||
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
: 0,
|
||||
loadingLabel: state.loading
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
? t('retrying mapped ALPR cameras')
|
||||
: t('loading mapped ALPR cameras')
|
||||
: state.noCoverage
|
||||
? 'No ALPR data for this area — US and Canada only'
|
||||
? t('No ALPR data for this area — US and Canada only')
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
? t('Zoom in to load mapped cameras')
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.stale ? t('Showing cached locations') : '',
|
||||
state.saturated || state.renderSaturated
|
||||
? 'Coverage limited — zoom in'
|
||||
? t('Coverage limited — zoom in')
|
||||
: '',
|
||||
state.status === 'empty'
|
||||
? t('No ALPR data for this area')
|
||||
: '',
|
||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
||||
state.status === 'ready' && state.onScreen === 0
|
||||
? 'None on screen — nearby cameras are outside the view'
|
||||
? t('None on screen — nearby cameras are outside the view')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { boxContains } from './model.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { createAlprFloorResolver } from './floors.js';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import {
|
||||
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
layerName: 'ALPR Cameras',
|
||||
source:
|
||||
source.attribution?.description || source.label || 'Camera source',
|
||||
label: 'ALPR camera',
|
||||
label: t('ALPR camera'),
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from '../../sources/vectorTiles.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||
import { decodeAlprTile } from './tileRecords.js';
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
||||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
|
||||
for (const source of sources) source.clear();
|
||||
precision.clear();
|
||||
},
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: '© OpenStreetMap contributors, ODbL',
|
||||
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
|
||||
}
|
||||
return {
|
||||
fetch: fetchAlprNodes,
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
/** Format a cohort total without presenting a retained cap as exact. */
|
||||
@@ -15,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
|
||||
: count;
|
||||
}
|
||||
|
||||
/** The count as the panel shows it, in the UI language. */
|
||||
function presentAwarenessCount(summary = {}) {
|
||||
if (!Number.isFinite(summary.count)) return '?';
|
||||
const count = formatNumber(summary.count);
|
||||
return summary.complete === false || summary.truncated === true
|
||||
? t('At least {count}', { count })
|
||||
: count;
|
||||
}
|
||||
|
||||
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
|
||||
function presentAwarenessDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
|
||||
const digits = meters < 10000 ? 1 : 0;
|
||||
return formatQuantity(meters / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
/** The cohort's reason, kept English in data, shown in the UI language. */
|
||||
function presentAwarenessReason(summary = {}) {
|
||||
return summary.reasonTemplate
|
||||
? t(summary.reasonTemplate, summary.reasonValues)
|
||||
: translateKnown(summary.reason || '');
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
@@ -71,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function rowHtml(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = formatAwarenessCount(summary);
|
||||
const count = presentAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
@@ -79,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
@@ -96,7 +123,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
@@ -176,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -184,7 +211,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
// The reason stays English for the voice analyst; the Contacts panel shows it
|
||||
// in the UI language from `reasonTemplate`. Kept local so this module stays
|
||||
// free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
const fillTemplate = (template, values = {}) =>
|
||||
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
|
||||
const withReason = (summary, template, values) => ({
|
||||
...summary,
|
||||
reason: fillTemplate(template, values),
|
||||
reasonTemplate: template,
|
||||
reasonValues: values,
|
||||
});
|
||||
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||
|
||||
export function createQueries({ state: layerState, services, parts, source }) {
|
||||
@@ -119,19 +132,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// A subject window is its own bounded area: name it instead of the viewport.
|
||||
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
||||
const km = Math.round(coverage.radiusM / 1000);
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? `mapped matches within ${km} km of the subject`
|
||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
||||
};
|
||||
return withReason(
|
||||
summary,
|
||||
summary.count
|
||||
? msg('mapped matches within {km} km of the subject')
|
||||
: msg('none mapped within {km} km; not a complete 250 km survey'),
|
||||
{ km },
|
||||
);
|
||||
}
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? 'mapped matches from the loaded viewport'
|
||||
: 'viewport feed is not a complete 250 km survey',
|
||||
};
|
||||
return withReason(
|
||||
summary,
|
||||
summary.count
|
||||
? msg('mapped matches from the loaded viewport')
|
||||
: msg('viewport feed is not a complete 250 km survey'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-18
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-midtown-w',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Midtown West @ 34th',
|
||||
label: 'Midtown West @ 34th', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: -70,
|
||||
headingDeg: 206,
|
||||
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-wtc-n',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 2,
|
||||
label: 'WTC North Plaza',
|
||||
label: 'WTC North Plaza', // i18n-ignore: camera name
|
||||
offsetNorthM: 95,
|
||||
offsetEastM: 34,
|
||||
headingDeg: 164,
|
||||
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-times-square-ne',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Times Sq Northeast',
|
||||
label: 'Times Sq Northeast', // i18n-ignore: camera name
|
||||
offsetNorthM: 230,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 218,
|
||||
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-market-5th',
|
||||
cityId: 'sf',
|
||||
poiIndex: 2,
|
||||
label: 'Market & 5th',
|
||||
label: 'Market & 5th', // i18n-ignore: camera name
|
||||
offsetNorthM: -160,
|
||||
offsetEastM: 80,
|
||||
headingDeg: 320,
|
||||
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-financial-district',
|
||||
cityId: 'sf',
|
||||
poiIndex: 1,
|
||||
label: 'SF Financial Core',
|
||||
label: 'SF Financial Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: 52,
|
||||
headingDeg: 205,
|
||||
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-shibuya-scramble',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 4,
|
||||
label: 'Shibuya Crossing',
|
||||
label: 'Shibuya Crossing', // i18n-ignore: camera name
|
||||
offsetNorthM: 180,
|
||||
offsetEastM: 46,
|
||||
headingDeg: 18,
|
||||
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-ginza-core',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 0,
|
||||
label: 'Ginza Core',
|
||||
label: 'Ginza Core', // i18n-ignore: camera name
|
||||
offsetNorthM: -180,
|
||||
offsetEastM: 150,
|
||||
headingDeg: 245,
|
||||
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-asakusa-n',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 3,
|
||||
label: 'Asakusa North Gate',
|
||||
label: 'Asakusa North Gate', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: -65,
|
||||
headingDeg: 192,
|
||||
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-city-a1',
|
||||
cityId: 'london',
|
||||
poiIndex: 4,
|
||||
label: 'City Cluster A1',
|
||||
label: 'City Cluster A1', // i18n-ignore: camera name
|
||||
offsetNorthM: 80,
|
||||
offsetEastM: 65,
|
||||
headingDeg: 220,
|
||||
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-soho-core',
|
||||
cityId: 'london',
|
||||
poiIndex: 2,
|
||||
label: 'Soho Core',
|
||||
label: 'Soho Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 210,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 206,
|
||||
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-rivoli',
|
||||
cityId: 'paris',
|
||||
poiIndex: 4,
|
||||
label: 'Rue de Rivoli',
|
||||
label: 'Rue de Rivoli', // i18n-ignore: camera name
|
||||
offsetNorthM: 55,
|
||||
offsetEastM: 85,
|
||||
headingDeg: 248,
|
||||
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-champs-n',
|
||||
cityId: 'paris',
|
||||
poiIndex: 1,
|
||||
label: 'Champs-Élysées North',
|
||||
label: 'Champs-Élysées North', // i18n-ignore: camera name
|
||||
offsetNorthM: 130,
|
||||
offsetEastM: -38,
|
||||
headingDeg: 175,
|
||||
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-mall-center',
|
||||
cityId: 'dc',
|
||||
poiIndex: 1,
|
||||
label: 'National Mall Center',
|
||||
label: 'National Mall Center', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: 20,
|
||||
headingDeg: 258,
|
||||
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-pentagon-s',
|
||||
cityId: 'dc',
|
||||
poiIndex: 3,
|
||||
label: 'Pentagon South',
|
||||
label: 'Pentagon South', // i18n-ignore: camera name
|
||||
offsetNorthM: -100,
|
||||
offsetEastM: 92,
|
||||
headingDeg: 14,
|
||||
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-difc-loop',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 4,
|
||||
label: 'DIFC Loop',
|
||||
label: 'DIFC Loop', // i18n-ignore: camera name
|
||||
offsetNorthM: 92,
|
||||
offsetEastM: -45,
|
||||
headingDeg: 196,
|
||||
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-downtown-east',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 0,
|
||||
label: 'Downtown East',
|
||||
label: 'Downtown East', // i18n-ignore: camera name
|
||||
offsetNorthM: -130,
|
||||
offsetEastM: 190,
|
||||
headingDeg: 322,
|
||||
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-congress-s',
|
||||
cityId: 'austin',
|
||||
poiIndex: 0,
|
||||
label: 'Congress Southbound',
|
||||
label: 'Congress Southbound', // i18n-ignore: camera name
|
||||
offsetNorthM: -165,
|
||||
offsetEastM: 40,
|
||||
headingDeg: 12,
|
||||
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-downtown-west',
|
||||
cityId: 'austin',
|
||||
poiIndex: 1,
|
||||
label: 'Downtown West',
|
||||
label: 'Downtown West', // i18n-ignore: camera name
|
||||
offsetNorthM: -120,
|
||||
offsetEastM: -160,
|
||||
headingDeg: 120,
|
||||
|
||||
@@ -14,23 +14,32 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
value
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
|
||||
: t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
);
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
PTC: msg('Potential tropical cyclone'),
|
||||
HU: msg('Hurricane'),
|
||||
TS: msg('Tropical storm'),
|
||||
TD: msg('Tropical depression'),
|
||||
SS: msg('Subtropical storm'),
|
||||
SD: msg('Subtropical depression'),
|
||||
});
|
||||
const classificationName = (code) =>
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
||||
? t(CLASSIFICATION_NAMES[code])
|
||||
: code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
value === null ? t('Unavailable') : formatQuantity(value, unit);
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
@@ -316,41 +325,68 @@ export function createCyclonesLayer({
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
? t('Track and cone match this advisory')
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
? t('Track/cone awaiting advisory {number}', {
|
||||
number: storm.advisoryNumber,
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
? t('Loading advisories…')
|
||||
: storm && !coherentCycloneGeometry(storm)
|
||||
? geometry
|
||||
: null);
|
||||
const stormCount = snapshot?.storms.length || 0;
|
||||
const activeStorms = tn(
|
||||
stormCount,
|
||||
'{count} active storm',
|
||||
'{count} active storms',
|
||||
);
|
||||
const position = storm
|
||||
? t('Position as of {time}', { time: utc(storm.positionAt) })
|
||||
: '';
|
||||
const intensity = storm
|
||||
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
|
||||
wind: number(storm.windKt, 'kt'),
|
||||
pressure: number(storm.pressureHpa, 'hPa'),
|
||||
})
|
||||
: '';
|
||||
const detail = storm
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
? t('{name} · {classification} · Advisory {number} · issued {time}', {
|
||||
name: storm.name,
|
||||
classification: classificationName(storm.classification),
|
||||
number: storm.advisoryNumber,
|
||||
time: utc(storm.issuedAt),
|
||||
})
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
? t('No active NHC/CPHC systems')
|
||||
: stormCount
|
||||
? activeStorms
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
? t('Loading advisories…')
|
||||
: t('Advisories unavailable');
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
label: t('Cyclones · NHC / CPHC'),
|
||||
coverage: t('Atlantic · E/C Pacific'),
|
||||
compact: stormCount
|
||||
? storm
|
||||
? t('{storms} · {name} selected', {
|
||||
storms: activeStorms,
|
||||
name: storm.name,
|
||||
})
|
||||
: activeStorms
|
||||
: detail,
|
||||
actions: storm?.advisoryUrl
|
||||
? [
|
||||
{
|
||||
id: 'advisory',
|
||||
label: 'Official advisory ↗',
|
||||
label: t('Official advisory ↗'),
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
@@ -360,12 +396,12 @@ export function createCyclonesLayer({
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
text: position,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
text: intensity,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
@@ -375,12 +411,12 @@ export function createCyclonesLayer({
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
|
||||
items: (snapshot?.storms || []).map((item, index) => ({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
@@ -388,15 +424,22 @@ export function createCyclonesLayer({
|
||||
chips: [],
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
{
|
||||
label: t('Advisory center / forecast track'),
|
||||
color: '#7fe6ed',
|
||||
},
|
||||
{
|
||||
label: t('Center-track uncertainty cone'),
|
||||
color: '#7fe6ed44',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
|
||||
infoTitle: t(
|
||||
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
||||
),
|
||||
};
|
||||
return controls;
|
||||
},
|
||||
|
||||
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
|
||||
1,
|
||||
);
|
||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
||||
assert.match(controls.info, /09-16 03:00 UTC/);
|
||||
// No line break inside the date: a word joiner holds the hyphen.
|
||||
assert.match(controls.info, /issued 09-\u206016\u00a003:00\u00a0UTC/);
|
||||
assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/);
|
||||
assert.match(controls.infoTitle, /not storm size/);
|
||||
assert.match(controls.infoTitle, /follows the surface/);
|
||||
assert.equal(controls.list.items[1].params.focus, true);
|
||||
|
||||
+350
-53
@@ -17,10 +17,9 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
} from '../../data/routeSteps.js';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -31,9 +30,9 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
|
||||
/** Travel modes, keyed by the `/api/route` profile name. */
|
||||
export const DIRECTIONS_MODES = Object.freeze({
|
||||
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
|
||||
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
|
||||
});
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
@@ -68,6 +67,270 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
|
||||
/** How often the row repaints the highlighted step while FLY is running (ms). */
|
||||
export const FLIGHT_PROGRESS_MS = 250;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display text: distances, durations and maneuvers in the page's language.
|
||||
// The proxy's English `instruction` stays the machine value; the row and the
|
||||
// cards phrase each step from its OSRM type/modifier/road instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
|
||||
* in src/data/routeSteps.js); number and unit never break apart.
|
||||
* @param {number} meters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function routeDistanceText(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '';
|
||||
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
|
||||
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
|
||||
const km = meters / 1000;
|
||||
return km < 10
|
||||
? formatQuantity(km, 'km', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: formatQuantity(Math.round(km), 'km');
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
|
||||
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function routeDurationText(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
function directionWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'direction' });
|
||||
case 'uturn':
|
||||
return t('around', { context: 'direction' });
|
||||
default:
|
||||
return modifier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The side to keep at a fork. English reuses the turn words; a language may
|
||||
* need another form here ("держитесь левее", not "держитесь налево").
|
||||
*/
|
||||
function forkWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'fork direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'fork direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'fork direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'fork direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'fork direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'fork direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'fork direction' });
|
||||
default:
|
||||
return directionWord(modifier);
|
||||
}
|
||||
}
|
||||
|
||||
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
|
||||
function laneWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'lane direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'lane direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'lane direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'lane direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'lane direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'lane direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'lane direction' });
|
||||
default:
|
||||
return directionWord(modifier);
|
||||
}
|
||||
}
|
||||
|
||||
/** "1st", "2nd", … for a roundabout exit. */
|
||||
function exitOrdinal(exit) {
|
||||
const number = formatNumber(exit);
|
||||
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
|
||||
case 'one':
|
||||
return t('{number}st', { context: 'ordinal', number });
|
||||
case 'two':
|
||||
return t('{number}nd', { context: 'ordinal', number });
|
||||
case 'few':
|
||||
return t('{number}rd', { context: 'ordinal', number });
|
||||
default:
|
||||
return t('{number}th', { context: 'ordinal', number });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One maneuver in the page's language, from the step's OSRM type, modifier,
|
||||
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
|
||||
* word for word. A step without a type (an older payload) keeps the proxy's
|
||||
* English instruction.
|
||||
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
|
||||
* @returns {string}
|
||||
*/
|
||||
export function directionsInstruction(step) {
|
||||
const type =
|
||||
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
|
||||
if (!type)
|
||||
return typeof step?.instruction === 'string' ? step.instruction : '';
|
||||
const modifier =
|
||||
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
|
||||
const road = roadLabel(step);
|
||||
const direction = directionWord(modifier);
|
||||
const values = { road, direction };
|
||||
switch (type) {
|
||||
case 'depart':
|
||||
return road ? t('Head out on {road}', values) : t('Head out');
|
||||
case 'arrive':
|
||||
if (modifier === 'left')
|
||||
return t('Arrive at your destination, on the left');
|
||||
if (modifier === 'right')
|
||||
return t('Arrive at your destination, on the right');
|
||||
return t('Arrive at your destination');
|
||||
case 'turn':
|
||||
if (modifier === 'uturn')
|
||||
return road
|
||||
? t('Make a U-turn onto {road}', values)
|
||||
: t('Make a U-turn');
|
||||
if (modifier === 'straight')
|
||||
return road
|
||||
? t('Continue straight onto {road}', values)
|
||||
: t('Continue straight');
|
||||
if (direction)
|
||||
return road
|
||||
? t('Turn {direction} onto {road}', values)
|
||||
: t('Turn {direction}', values);
|
||||
return road
|
||||
? t('Turn onto {road}', values)
|
||||
: t('Turn', { context: 'maneuver' });
|
||||
case 'continue':
|
||||
if (modifier === 'uturn')
|
||||
return road
|
||||
? t('Make a U-turn onto {road}', values)
|
||||
: t('Make a U-turn');
|
||||
if (direction && modifier !== 'straight')
|
||||
return road
|
||||
? t('Continue {direction} onto {road}', values)
|
||||
: t('Continue {direction}', values);
|
||||
return road
|
||||
? t('Continue straight onto {road}', values)
|
||||
: t('Continue straight');
|
||||
case 'end of road':
|
||||
if (direction)
|
||||
return road
|
||||
? t('At the end of the road, turn {direction} onto {road}', values)
|
||||
: t('At the end of the road, turn {direction}', values);
|
||||
return road
|
||||
? t('At the end of the road, continue onto {road}', values)
|
||||
: t('At the end of the road, continue');
|
||||
case 'fork':
|
||||
if (direction) {
|
||||
const fork = { road, direction: forkWord(modifier) };
|
||||
return road
|
||||
? t('Keep {direction} at the fork onto {road}', fork)
|
||||
: t('Keep {direction} at the fork', fork);
|
||||
}
|
||||
return road
|
||||
? t('Continue at the fork onto {road}', values)
|
||||
: t('Continue at the fork');
|
||||
case 'merge':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Merge {direction} onto {road}', values)
|
||||
: t('Merge {direction}', values);
|
||||
return road
|
||||
? t('Merge onto {road}', values)
|
||||
: t('Merge', { context: 'maneuver' });
|
||||
case 'on ramp':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Take the ramp on the {direction} onto {road}', values)
|
||||
: t('Take the ramp on the {direction}', values);
|
||||
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
|
||||
case 'off ramp':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Take the exit on the {direction} onto {road}', values)
|
||||
: t('Take the exit on the {direction}', values);
|
||||
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
|
||||
case 'roundabout':
|
||||
case 'rotary':
|
||||
if (Number.isInteger(step.exit) && step.exit >= 1) {
|
||||
const exit = exitOrdinal(step.exit);
|
||||
return road
|
||||
? t('At the roundabout, take the {exit} exit onto {road}', {
|
||||
exit,
|
||||
road,
|
||||
})
|
||||
: t('At the roundabout, take the {exit} exit', { exit });
|
||||
}
|
||||
return road
|
||||
? t('Enter the roundabout onto {road}', values)
|
||||
: t('Enter the roundabout');
|
||||
case 'roundabout turn':
|
||||
if (direction)
|
||||
return road
|
||||
? t('At the roundabout, turn {direction} onto {road}', values)
|
||||
: t('At the roundabout, turn {direction}', values);
|
||||
return road
|
||||
? t('At the roundabout, continue onto {road}', values)
|
||||
: t('At the roundabout, continue');
|
||||
case 'exit roundabout':
|
||||
case 'exit rotary':
|
||||
return road
|
||||
? t('Exit the roundabout onto {road}', values)
|
||||
: t('Exit the roundabout');
|
||||
case 'use lane':
|
||||
if (direction) {
|
||||
const lane = { road, direction: laneWord(modifier) };
|
||||
return road
|
||||
? t('Use the {direction} lane onto {road}', lane)
|
||||
: t('Use the {direction} lane', lane);
|
||||
}
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
: t('Continue', { context: 'maneuver' });
|
||||
default:
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
: t('Continue', { context: 'maneuver' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,32 +370,35 @@ export function directionsRowControls(state) {
|
||||
const routing = status === 'routing';
|
||||
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
|
||||
id: `mode-${id}`,
|
||||
label: spec.chip,
|
||||
label: t(spec.chip),
|
||||
active: mode === id,
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
chips.push({
|
||||
id: 'set-a',
|
||||
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
|
||||
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
|
||||
active: armed === 'a',
|
||||
state: armed === 'a' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'a'
|
||||
? 'Click a spot on the globe to place A (click again to cancel)'
|
||||
: 'Then click the globe to place the start',
|
||||
? t('Click a spot on the globe to place A (click again to cancel)')
|
||||
: t('Then click the globe to place the start'),
|
||||
params: { arm: armed === 'a' ? null : 'a' },
|
||||
});
|
||||
chips.push({
|
||||
id: 'set-b',
|
||||
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
|
||||
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
|
||||
active: armed === 'b',
|
||||
state: armed === 'b' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'b'
|
||||
? 'Click a spot on the globe to place B (click again to cancel)'
|
||||
: 'Then click the globe to place the destination',
|
||||
? t('Click a spot on the globe to place B (click again to cancel)')
|
||||
: t('Then click the globe to place the destination'),
|
||||
params: { arm: armed === 'b' ? null : 'b' },
|
||||
});
|
||||
chips.push({
|
||||
@@ -140,26 +406,28 @@ export function directionsRowControls(state) {
|
||||
label: '⇄',
|
||||
disabled: !(a && b) || routing,
|
||||
state: 'idle',
|
||||
title: 'Swap A and B',
|
||||
title: t('Swap A and B'),
|
||||
params: { swap: true },
|
||||
});
|
||||
const flying = Number.isInteger(flightStep);
|
||||
chips.push({
|
||||
id: 'fly',
|
||||
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
|
||||
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
|
||||
disabled: !route || routing,
|
||||
busy: routing || flying,
|
||||
active: flying,
|
||||
state: routing || flying ? 'loading' : 'idle',
|
||||
title: route ? 'Fly the camera along the route' : 'Place A and B first',
|
||||
title: route
|
||||
? t('Fly the camera along the route')
|
||||
: t('Place A and B first'),
|
||||
params: { fly: true },
|
||||
});
|
||||
chips.push({
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
label: t('CLEAR', { context: 'action' }),
|
||||
disabled: !a && !b && !route,
|
||||
state: 'idle',
|
||||
title: 'Remove the route and both markers',
|
||||
title: t('Remove the route and both markers'),
|
||||
params: { clear: true },
|
||||
});
|
||||
return {
|
||||
@@ -179,8 +447,8 @@ export function directionsRowControls(state) {
|
||||
*/
|
||||
export const POINTER_TOOL_EXITS = Object.freeze({
|
||||
draw: Object.freeze({
|
||||
name: 'Draw',
|
||||
leave: 'press Escape twice to leave Draw',
|
||||
name: msg('Draw'),
|
||||
leave: msg('press Escape twice to leave Draw'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -201,9 +469,16 @@ export function pointerBlockedMessage(owner, which) {
|
||||
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
|
||||
const endpoint = which === 'b' ? 'B' : 'A';
|
||||
const known = POINTER_TOOL_EXITS[id];
|
||||
const name = known?.name || id;
|
||||
const leave = known?.leave || `turn ${name} off`;
|
||||
return `${name} is active — ${leave}, then set ${endpoint}`;
|
||||
if (!known)
|
||||
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||
name: id,
|
||||
endpoint,
|
||||
});
|
||||
return t('{name} is active — {leave}, then set {endpoint}', {
|
||||
name: t(known.name),
|
||||
leave: t(known.leave),
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,8 +547,8 @@ export function directionsStepList({
|
||||
const items = steps.map((step, index) => ({
|
||||
id: `step-${index}`,
|
||||
ordinal: index + 1,
|
||||
lead: formatRouteDistance(step.distanceM) || '—',
|
||||
text: step.instruction,
|
||||
lead: routeDistanceText(step.distanceM) || '—',
|
||||
text: directionsInstruction(step),
|
||||
active: index === active,
|
||||
current: Number.isInteger(flightStep) && index === flightStep,
|
||||
params: { step: index },
|
||||
@@ -285,13 +560,15 @@ export function directionsStepList({
|
||||
id: 'step-truncated',
|
||||
ordinal: items.length + 1,
|
||||
lead: '',
|
||||
text: `Only the first ${steps.length} turns of this route are shown`,
|
||||
text: t('Only the first {count} turns of this route are shown', {
|
||||
count: steps.length,
|
||||
}),
|
||||
active: false,
|
||||
current: false,
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
return { ariaLabel: 'Turn-by-turn directions', items };
|
||||
return { ariaLabel: t('Turn-by-turn directions'), items };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,12 +608,13 @@ export function directionsStats(state) {
|
||||
armed,
|
||||
pointerBlocked,
|
||||
} = state;
|
||||
const source = 'OSM routing';
|
||||
// English in layer state; the layer row translates it.
|
||||
const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel
|
||||
if (pointerBlocked) {
|
||||
return {
|
||||
count: route?.steps.length || 0,
|
||||
lastUpdate,
|
||||
error: 'Another map tool is using clicks — close it, then SET A again',
|
||||
error: t('Another map tool is using clicks — close it, then SET A again'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -347,7 +625,7 @@ export function directionsStats(state) {
|
||||
lastUpdate,
|
||||
error: null,
|
||||
loading: true,
|
||||
loadingLabel: 'Routing…',
|
||||
loadingLabel: t('Routing…'),
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -355,7 +633,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || 'No route found',
|
||||
error: error || t('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -364,10 +642,17 @@ export function directionsStats(state) {
|
||||
// set (not only while loading), so the route summary and the placement
|
||||
// guidance ride on it; `coverage` carries the same text for stats readers.
|
||||
if (route) {
|
||||
const word = DIRECTIONS_MODES[mode]?.word || mode;
|
||||
const summary =
|
||||
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
|
||||
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
|
||||
const spec = DIRECTIONS_MODES[mode];
|
||||
const word = spec ? t(spec.word) : mode;
|
||||
const values = {
|
||||
distance: routeDistanceText(route.distanceM),
|
||||
duration: routeDurationText(route.durationS),
|
||||
mode: word,
|
||||
count: route.steps.length,
|
||||
};
|
||||
const summary = route.stepsTruncated
|
||||
? t('{distance} · {duration} · {mode} · first {count} turns', values)
|
||||
: t('{distance} · {duration} · {mode}', values);
|
||||
return {
|
||||
count: route.steps.length,
|
||||
lastUpdate,
|
||||
@@ -378,10 +663,13 @@ export function directionsStats(state) {
|
||||
};
|
||||
}
|
||||
let coverage;
|
||||
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
|
||||
else if (a && !b) coverage = 'SET B, then click the globe';
|
||||
else if (!a && b) coverage = 'SET A, then click the globe';
|
||||
else coverage = 'SET A, then click the globe';
|
||||
if (armed)
|
||||
coverage = t('Click the globe to place {endpoint}', {
|
||||
endpoint: armed.toUpperCase(),
|
||||
});
|
||||
else if (a && !b) coverage = t('SET B, then click the globe');
|
||||
else if (!a && b) coverage = t('SET A, then click the globe');
|
||||
else coverage = t('SET A, then click the globe');
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
@@ -403,14 +691,23 @@ export function directionsStepCopy(steps, index) {
|
||||
const step = steps[index];
|
||||
const details = [];
|
||||
const leg = [];
|
||||
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
|
||||
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
|
||||
const position = { step: index + 1, total: steps.length };
|
||||
details.push(
|
||||
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
|
||||
leg.length
|
||||
? t('Step {step} of {total} · then {leg}', {
|
||||
...position,
|
||||
leg: leg.join(' · '),
|
||||
})
|
||||
: t('Step {step} of {total}', position),
|
||||
);
|
||||
const next = steps[index + 1];
|
||||
if (next) details.push(`Then: ${next.instruction}`);
|
||||
return { title: step.instruction, details };
|
||||
if (next)
|
||||
details.push(
|
||||
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
|
||||
);
|
||||
return { title: directionsInstruction(step), details };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -819,10 +1116,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_status = 'error';
|
||||
_error =
|
||||
payload?.error === 'no route found'
|
||||
? 'No route found between A and B'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? `Routing failed: ${payload.error}`
|
||||
: 'No route found between A and B';
|
||||
? t('Routing failed: {error}', { error: payload.error })
|
||||
: t('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
_route = route;
|
||||
@@ -839,10 +1136,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// happened instead.
|
||||
_error =
|
||||
error?.name === 'AbortError'
|
||||
? 'Routing timed out'
|
||||
? t('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? 'Routing unavailable — no response from the routing service'
|
||||
: error?.message || 'Routing unavailable';
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
: error?.message || t('Routing unavailable');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (_routeAbort === controller) _routeAbort = null;
|
||||
@@ -1037,7 +1334,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
label: 'Directions',
|
||||
label: t('Directions'),
|
||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
},
|
||||
],
|
||||
|
||||
+67
-10
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -332,11 +332,11 @@ export function createModel({
|
||||
function buildSelectedFireCard(fire, nowMs) {
|
||||
const meta = [
|
||||
t('{value} conf', {
|
||||
value: confidenceBucket(fire.confidence),
|
||||
value: confidenceLabel(confidenceBucket(fire.confidence)),
|
||||
}),
|
||||
];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
const age = formatAgeLabel(nowMs - fire.acqMs);
|
||||
if (age) meta.push(t('{value} ago', { value: age }));
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite);
|
||||
@@ -351,10 +351,10 @@ export function createModel({
|
||||
cullPosition: fireCullPosition(fire),
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
title: t('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||
details: [
|
||||
meta.join(' · '),
|
||||
formatLatLon(fire.lat, fire.lon) +
|
||||
formatLatLonLabel(fire.lat, fire.lon) +
|
||||
(fire.night ? ` · ${t('NIGHT')}` : ''),
|
||||
],
|
||||
selected: true,
|
||||
@@ -374,9 +374,9 @@ export function createModel({
|
||||
|
||||
function buildFireCard(candidate, nowMs) {
|
||||
const fire = candidate.fire;
|
||||
const meta = [confidenceBucket(fire.confidence)];
|
||||
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
const age = formatAgeLabel(nowMs - fire.acqMs);
|
||||
if (age) meta.push(age);
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite) || fire.sensor;
|
||||
@@ -388,7 +388,7 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
title: t('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||
details: [meta.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(fire.frp) || 0,
|
||||
@@ -407,9 +407,9 @@ export function createModel({
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
||||
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
|
||||
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAge(nowMs - cell.newestAcqMs);
|
||||
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
|
||||
if (age) parts.push(t('new {value}', { value: age }));
|
||||
}
|
||||
return {
|
||||
@@ -483,6 +483,37 @@ export function createModel({
|
||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
||||
}
|
||||
|
||||
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
|
||||
|
||||
function formatFrpLabel(frp) {
|
||||
const digits = frp >= 10 ? 0 : 1;
|
||||
return formatNumber(frp, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** formatLatLon for a card: "8,389° ю. ш. 44,162° з. д." in Russian. */
|
||||
|
||||
function formatLatLonLabel(lat, lon) {
|
||||
const amount = (value) =>
|
||||
formatNumber(Math.abs(value), {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
const latPart =
|
||||
lat >= 0
|
||||
? t('{value}°N', { value: amount(lat) })
|
||||
: t('{value}°S', { value: amount(lat) });
|
||||
const lonPart =
|
||||
lon >= 0
|
||||
? t('{value}°E', { value: amount(lon) })
|
||||
: t('{value}°W', { value: amount(lon) });
|
||||
return `${latPart} ${lonPart}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
@@ -503,6 +534,32 @@ export function createModel({
|
||||
return t('{count}h ago', { count: Math.round(minutes / 60) });
|
||||
}
|
||||
|
||||
/** A confidence bucket as a card shows it, in the UI language. */
|
||||
|
||||
function confidenceLabel(bucket) {
|
||||
switch (bucket) {
|
||||
case 'high':
|
||||
return t('high', { context: 'fire confidence' });
|
||||
case 'nominal':
|
||||
return t('nominal', { context: 'fire confidence' });
|
||||
case 'low':
|
||||
return t('low', { context: 'fire confidence' });
|
||||
default:
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
|
||||
|
||||
function formatAgeLabel(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return t('<1h', { context: 'age' });
|
||||
if (hours < 48)
|
||||
return t('{count}h', { context: 'age', count: Math.round(hours) });
|
||||
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
function confidenceBucket(confidence) {
|
||||
|
||||
@@ -18,7 +18,9 @@ export function createQueries({
|
||||
if (!fire) return null;
|
||||
return {
|
||||
id: fireDetectionKey(fire),
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
frp: fire.frp,
|
||||
@@ -115,7 +117,9 @@ export function createQueries({
|
||||
latitude: strongest.lat,
|
||||
longitude: strongest.lon,
|
||||
frp: strongest.frp,
|
||||
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(strongest.frp),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { CONTEXT_TOP_N } from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function createSelection({
|
||||
layerState,
|
||||
@@ -80,7 +81,7 @@ export function createSelection({
|
||||
requestWorldFocus({
|
||||
kind: 'fire',
|
||||
id: fireDetectionKey(fire),
|
||||
label: 'FIRE',
|
||||
label: t('FIRE'),
|
||||
position: components.model.firePosition(fire),
|
||||
});
|
||||
}
|
||||
@@ -192,7 +193,9 @@ export function createSelection({
|
||||
layerName: name,
|
||||
source: 'NASA FIRMS',
|
||||
dataSource: layerState._dataSource,
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
properties: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||
import {
|
||||
@@ -752,8 +753,15 @@ export function createTracking({
|
||||
// the tail number rather than raw hex.
|
||||
const cs = parts.queries._contactLabel(icao24, info);
|
||||
const altFt = Math.round((info.altitude || 0) * 3.28084);
|
||||
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
|
||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
||||
// Flight levels are an aviation code; feet and knots read in the active
|
||||
// language ("0 фт · 1 уз").
|
||||
const quantity = (value, unit) =>
|
||||
`${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
|
||||
const fl =
|
||||
altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
|
||||
const spd = info.velocity
|
||||
? quantity(Math.round(info.velocity * 1.944), 'kts')
|
||||
: '';
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? t('STALE')
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
|
||||
* the UI language. The class itself stays an English identifier in data.
|
||||
* @param {string} klass
|
||||
* @returns {string}
|
||||
*/
|
||||
export function installationClassLabel(klass) {
|
||||
const key = String(klass || 'installation');
|
||||
switch (key) {
|
||||
case 'airfield':
|
||||
return t('AIRFIELD', { context: 'installation class' });
|
||||
case 'naval_base':
|
||||
return t('NAVAL BASE', { context: 'installation class' });
|
||||
case 'range':
|
||||
return t('RANGE', { context: 'installation class' });
|
||||
case 'military_land':
|
||||
return t('MILITARY LAND', { context: 'installation class' });
|
||||
case 'base':
|
||||
return t('BASE', { context: 'installation class' });
|
||||
case 'places_candidate':
|
||||
return t('PLACES CANDIDATE', { context: 'installation class' });
|
||||
case 'installation':
|
||||
return t('INSTALLATION', { context: 'installation class' });
|
||||
default:
|
||||
return key.replaceAll('_', ' ').toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
@@ -177,9 +178,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
@@ -273,9 +274,7 @@ export function createRendering({
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
|
||||
@@ -470,7 +470,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('SELECTED SPACE MISSION'),
|
||||
t('Selected Space Mission'),
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
||||
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
|
||||
{ label: t('Under 50% contained'), color: containmentAccent(1) },
|
||||
{ label: t('50–99% contained'), color: containmentAccent(50) },
|
||||
{ label: t('Fully contained'), color: containmentAccent(100) },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createCategories({
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: t('All') },
|
||||
{ id: 'all', label: t('All', { context: 'radio tag' }) },
|
||||
{ id: 'news', label: t('News') },
|
||||
{ id: 'talk', label: t('Talk') },
|
||||
{ id: 'weather', label: t('Weather / Emergency') },
|
||||
@@ -89,7 +89,7 @@ export function createCategories({
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
const id = `genre:${genre}`;
|
||||
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
||||
categories.push({ id, label });
|
||||
categories.push({ id, label: t(label) });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: t('Other') });
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import {
|
||||
RADIO_CATEGORY_COLORS,
|
||||
RADIO_CLUSTER_LABELS,
|
||||
RADIO_MARKER_CATEGORY_ORDER,
|
||||
} from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Short uppercase category word on a cluster badge, in the page language. */
|
||||
function radioClusterLabel(categoryId) {
|
||||
switch (categoryId) {
|
||||
case 'news':
|
||||
return t('NEWS', { context: 'radio' });
|
||||
case 'talk':
|
||||
return t('TALK', { context: 'radio' });
|
||||
case 'weather':
|
||||
return t('WEATHER', { context: 'radio' });
|
||||
case 'public-safety':
|
||||
return t('SAFETY', { context: 'radio' });
|
||||
case 'aviation-marine':
|
||||
return t('AIR / SEA', { context: 'radio' });
|
||||
case 'traffic-transit':
|
||||
return t('TRANSIT', { context: 'radio' });
|
||||
case 'music':
|
||||
return t('MUSIC', { context: 'radio' });
|
||||
default:
|
||||
return t('OTHER', { context: 'radio' });
|
||||
}
|
||||
}
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/** Return the shared CSS color for a canonical or detected-genre category. */
|
||||
@@ -18,7 +40,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
function radioClusterBadgeText(categoryId = 'other', count = 0) {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
|
||||
const label = radioClusterLabel(canonical);
|
||||
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
|
||||
return `${stationCount} ${label}`;
|
||||
}
|
||||
|
||||
+29
-36
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
|
||||
|
||||
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
|
||||
|
||||
// Genre labels are marked for i18n extraction; categories.js translates them.
|
||||
// Kept local so this module stays free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
|
||||
export const MUSIC_GENRES = Object.freeze([
|
||||
['alternative', 'Alternative'],
|
||||
['ambient', 'Ambient'],
|
||||
['blues', 'Blues'],
|
||||
['classical', 'Classical'],
|
||||
['country', 'Country'],
|
||||
['dance', 'Dance'],
|
||||
['electronic', 'Electronic'],
|
||||
['folk', 'Folk'],
|
||||
['funk', 'Funk'],
|
||||
['hip hop', 'Hip-Hop'],
|
||||
['house', 'House'],
|
||||
['indie', 'Indie'],
|
||||
['jazz', 'Jazz'],
|
||||
['latin', 'Latin'],
|
||||
['metal', 'Metal'],
|
||||
['oldies', 'Oldies'],
|
||||
['pop', 'Pop'],
|
||||
['punk', 'Punk'],
|
||||
['r&b', 'R&B'],
|
||||
['reggae', 'Reggae'],
|
||||
['rock', 'Rock'],
|
||||
['soul', 'Soul'],
|
||||
['techno', 'Techno'],
|
||||
['trance', 'Trance'],
|
||||
['world', 'World'],
|
||||
['alternative', msg('Alternative')],
|
||||
['ambient', msg('Ambient')],
|
||||
['blues', msg('Blues')],
|
||||
['classical', msg('Classical')],
|
||||
['country', msg('Country')],
|
||||
['dance', msg('Dance')],
|
||||
['electronic', msg('Electronic')],
|
||||
['folk', msg('Folk')],
|
||||
['funk', msg('Funk')],
|
||||
['hip hop', msg('Hip-Hop')],
|
||||
['house', msg('House')],
|
||||
['indie', msg('Indie')],
|
||||
['jazz', msg('Jazz')],
|
||||
['latin', msg('Latin')],
|
||||
['metal', msg('Metal')],
|
||||
['oldies', msg('Oldies')],
|
||||
['pop', msg('Pop')],
|
||||
['punk', msg('Punk')],
|
||||
['r&b', msg('R&B')],
|
||||
['reggae', msg('Reggae')],
|
||||
['rock', msg('Rock')],
|
||||
['soul', msg('Soul')],
|
||||
['techno', msg('Techno')],
|
||||
['trance', msg('Trance')],
|
||||
['world', msg('World')],
|
||||
]);
|
||||
|
||||
export const CATEGORY_MATCHERS = Object.freeze({
|
||||
@@ -127,17 +131,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
|
||||
other: '#9aa7b3',
|
||||
});
|
||||
|
||||
export const RADIO_CLUSTER_LABELS = Object.freeze({
|
||||
news: 'NEWS',
|
||||
talk: 'TALK',
|
||||
weather: 'WEATHER',
|
||||
'public-safety': 'SAFETY',
|
||||
'aviation-marine': 'AIR / SEA',
|
||||
'traffic-transit': 'TRANSIT',
|
||||
music: 'MUSIC',
|
||||
other: 'OTHER',
|
||||
});
|
||||
|
||||
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
|
||||
'news',
|
||||
'public-safety',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Recent Imagery text in the page's language: days, metres and the focused
|
||||
* day's readout. The model (`model.js`) stays English and i18n-free because AI
|
||||
* tool services import it outside the browser; this module is page-only.
|
||||
*/
|
||||
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { dayTime } from './model.js';
|
||||
|
||||
const SHORT_DAY = Object.freeze({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const LONG_DAY = Object.freeze({ ...SHORT_DAY, year: 'numeric' });
|
||||
|
||||
/** `Sep 18` / `18 сент.` for a `YYYY-MM-DD` day, in the page's language. */
|
||||
export function displayDay(day, options = SHORT_DAY) {
|
||||
const time = dayTime(day);
|
||||
return time === null ? '' : formatDate(time, options);
|
||||
}
|
||||
|
||||
/** `30 m` / `30 м`, number and unit kept together. */
|
||||
export const displayMetres = (value) => formatQuantity(value, 'm');
|
||||
|
||||
/** The readout's words in the page's language; the model keeps them English. */
|
||||
export const READOUT_WORDS = Object.freeze({
|
||||
date: (day) => displayDay(day, LONG_DAY),
|
||||
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
|
||||
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
|
||||
// (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
sensor: (sensor) =>
|
||||
t(sensor).replace(/(?<=[\p{L}\p{N}])-(?=[\p{L}\p{N}])/gu, '-\u2060'),
|
||||
metres: displayMetres,
|
||||
overview: () => t('overview'),
|
||||
cloud: (cloud) => {
|
||||
if (!cloud) return t('cloud unknown');
|
||||
const min = Math.round(cloud.min);
|
||||
const max = Math.round(cloud.max);
|
||||
return min === max
|
||||
? t('{percent}% scene cloud', { percent: min })
|
||||
: t('{min}–{max}% scene cloud', { min, max });
|
||||
},
|
||||
});
|
||||
@@ -28,11 +28,12 @@ import {
|
||||
parseCandidateKey,
|
||||
quantizeBox,
|
||||
rankLatest,
|
||||
shortDay,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
} from './model.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -46,10 +47,11 @@ export const SPLIT_PUBLISH_MS = 400;
|
||||
/** The three modes, in share-link order (`m` = index). */
|
||||
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
|
||||
|
||||
const COMPARISON_IN_USE = 'Comparison in use by another scene';
|
||||
const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
|
||||
const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
|
||||
const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
|
||||
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
|
||||
// Kept English in the snapshot (AI tools read it); the card translates them.
|
||||
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
|
||||
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
|
||||
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
|
||||
/** ZOOM IN's flight time, in seconds. */
|
||||
const ZOOM_FIT_SECONDS = 1.2;
|
||||
const DEFAULT_SPLIT = 0.5;
|
||||
@@ -748,15 +750,21 @@ export function createRecentImageryLayer({
|
||||
|
||||
function notes() {
|
||||
const out = [];
|
||||
if (_truncated) out.push('Catalog truncated · a newer clear day may exist');
|
||||
if (_truncated)
|
||||
out.push(t('Catalog truncated · a newer clear day may exist'));
|
||||
for (const error of _catalogErrors) {
|
||||
const label = PRODUCTS[error.product]?.label || error.product;
|
||||
out.push(`${label} catalog unavailable · ${error.message}`);
|
||||
out.push(
|
||||
t('{label} catalog unavailable · {detail}', {
|
||||
label,
|
||||
detail: error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const today = utcDay(now());
|
||||
const viirsToday = candidateFor(`VIIRS:${today}`);
|
||||
if (viirsToday?.availability === 'unknown' && _sources.viirs)
|
||||
out.push('Daily overview for today may still be empty until the pass');
|
||||
out.push(t('Daily overview for today may still be empty until the pass'));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -767,7 +775,7 @@ export function createRecentImageryLayer({
|
||||
key: key || null,
|
||||
candidate: candidateFor(key),
|
||||
label: parsed
|
||||
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
|
||||
: null,
|
||||
sourceOff,
|
||||
drapable: drapable(candidateFor(key)),
|
||||
@@ -1227,7 +1235,7 @@ export function createRecentImageryLayer({
|
||||
_alpha = 1;
|
||||
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
|
||||
}
|
||||
_notice = 'Box and images cleared';
|
||||
_notice = msg('Box and images cleared');
|
||||
syncRender();
|
||||
publish([...BOX_EDGES, 'a', 'b', 'split']);
|
||||
notify();
|
||||
@@ -1276,7 +1284,9 @@ export function createRecentImageryLayer({
|
||||
candidates,
|
||||
focusIndex,
|
||||
focus,
|
||||
readout: focus ? formatCandidateReadout(focus, now()) : '',
|
||||
readout: focus
|
||||
? formatCandidateReadout(focus, now(), READOUT_WORDS)
|
||||
: '',
|
||||
recommended: ranked.candidate
|
||||
? { key: ranked.candidate.key, reason: ranked.reason }
|
||||
: null,
|
||||
@@ -1329,17 +1339,18 @@ export function createRecentImageryLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'hls',
|
||||
label: 'MORE DETAIL · 30 m',
|
||||
label: t('MORE DETAIL · 30 m'),
|
||||
active: _sources.hls,
|
||||
title: 'Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days',
|
||||
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
|
||||
params: { hls: !_sources.hls },
|
||||
},
|
||||
{
|
||||
id: 'viirs',
|
||||
label: 'DAILY OVERVIEW · 250 m',
|
||||
label: t('DAILY OVERVIEW · 250 m'),
|
||||
active: _sources.viirs,
|
||||
title:
|
||||
title: t(
|
||||
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
|
||||
),
|
||||
params: { viirs: !_sources.viirs },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -228,7 +228,7 @@ test('a valid box searches and previews the START HERE day alone in IMAGE mode',
|
||||
assert.equal(snapshot.focus.key, S18, 'focus starts on it');
|
||||
assert.equal(snapshot.preview.key, S18);
|
||||
assert.equal(snapshot.preview.slot, 'a');
|
||||
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30 m');
|
||||
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30\u00a0m');
|
||||
assert.equal(snapshot.candidates[1].preview, true);
|
||||
const none = {
|
||||
key: null,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Pure domain model for the Recent Imagery layer: products, the selection box,
|
||||
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
|
||||
@@ -29,10 +33,10 @@ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
||||
*/
|
||||
export const PRODUCTS = Object.freeze({
|
||||
S30: Object.freeze({
|
||||
label: 'HLS S30',
|
||||
name: 'Sentinel-2',
|
||||
label: 'HLS S30', // i18n-ignore: product code
|
||||
name: msg('Sentinel-2'),
|
||||
source: 'hls',
|
||||
sensor: 'Sentinel-2 via HLS',
|
||||
sensor: msg('Sentinel-2 via HLS'),
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
@@ -41,10 +45,10 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
L30: Object.freeze({
|
||||
label: 'HLS L30',
|
||||
name: 'Landsat 8/9',
|
||||
label: 'HLS L30', // i18n-ignore: product code
|
||||
name: msg('Landsat 8/9'),
|
||||
source: 'hls',
|
||||
sensor: 'Landsat 8/9 via HLS',
|
||||
sensor: msg('Landsat 8/9 via HLS'),
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
VIIRS: Object.freeze({
|
||||
label: 'VIIRS',
|
||||
name: 'Daily overview',
|
||||
label: 'VIIRS', // i18n-ignore: product code
|
||||
name: msg('Daily overview'),
|
||||
source: 'viirs',
|
||||
sensor: 'VIIRS NOAA-21',
|
||||
resolutionM: 250,
|
||||
@@ -262,6 +266,11 @@ export function utcDay(value) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** UTC midnight of a `YYYY-MM-DD` day in epoch ms, for locale formatting; null otherwise. */
|
||||
export function dayTime(day) {
|
||||
return isValidDay(day) ? Date.parse(`${day}T00:00:00Z`) : null;
|
||||
}
|
||||
|
||||
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
|
||||
export function shortDay(day) {
|
||||
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
|
||||
@@ -479,15 +488,14 @@ export function rankLatest(
|
||||
return { candidate: null, reason: null, certain };
|
||||
}
|
||||
|
||||
function daysAgoLabel(day, now) {
|
||||
/** Whole UTC days from `day` to the day of `now`; null when either is invalid. */
|
||||
function daysBetween(day, now) {
|
||||
const today = utcDay(now);
|
||||
if (!today) return null;
|
||||
const delta = Math.round(
|
||||
return Math.round(
|
||||
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
||||
DAY_MS,
|
||||
);
|
||||
if (delta <= 0) return 'today';
|
||||
return delta === 1 ? 'yesterday' : `${delta} days ago`;
|
||||
}
|
||||
|
||||
function cloudLabel(cloud) {
|
||||
@@ -497,18 +505,40 @@ function cloudLabel(cloud) {
|
||||
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of the readout. The page passes its own (translated, locale
|
||||
* formatted) set; this module stays free of i18n because AI tool services
|
||||
* import it outside the browser.
|
||||
*/
|
||||
const READOUT_WORDS = Object.freeze({
|
||||
/** `Sep 18, 2026` for a valid `YYYY-MM-DD` day. */
|
||||
date: (day) => {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
return `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
},
|
||||
/** Age in whole UTC days, 0 or less for today. */
|
||||
daysAgo: (delta) =>
|
||||
delta <= 0 ? 'today' : delta === 1 ? 'yesterday' : `${delta} days ago`,
|
||||
sensor: (sensor) => sensor,
|
||||
metres: (value) => `${value} m`,
|
||||
overview: () => 'overview',
|
||||
/** `{ min, max }` scene cloud in percent, or null. */
|
||||
cloud: cloudLabel,
|
||||
});
|
||||
|
||||
/**
|
||||
* One-line UTC readout, e.g.
|
||||
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
||||
* @param {object} candidate
|
||||
* @param {string | number | Date} [now]
|
||||
* @param {Partial<typeof READOUT_WORDS>} [words] the page's localized words
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
export function formatCandidateReadout(candidate, now = new Date(), words) {
|
||||
const product = PRODUCTS[candidate?.product];
|
||||
if (!product || !candidate?.day) return '';
|
||||
const [y, m, d] = candidate.day.split('-').map(Number);
|
||||
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
if (!product || !isValidDay(candidate?.day)) return '';
|
||||
const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
|
||||
let when = say.date(candidate.day);
|
||||
if (candidate.timeRange?.start) {
|
||||
const start = hhmm(candidate.timeRange.start);
|
||||
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
||||
@@ -518,11 +548,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
: ` ${start}Z`;
|
||||
}
|
||||
const parts = [when];
|
||||
const ago = daysAgoLabel(candidate.day, now);
|
||||
if (ago) parts.push(ago);
|
||||
parts.push(product.sensor, `${product.resolutionM} m`);
|
||||
if (product.overview) parts.push('overview');
|
||||
parts.push(cloudLabel(candidate.cloud));
|
||||
const delta = daysBetween(candidate.day, now);
|
||||
if (delta !== null) parts.push(say.daysAgo(delta));
|
||||
parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
|
||||
if (product.overview) parts.push(say.overview());
|
||||
parts.push(say.cloud(candidate.cloud));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
id: String(ISS_NORAD),
|
||||
position,
|
||||
variant: 'label',
|
||||
title: 'ISS',
|
||||
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
|
||||
accent: '#ff4444',
|
||||
priority: 1000,
|
||||
collisionGroup: 'ambient-label',
|
||||
|
||||
@@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) {
|
||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
|
||||
noradId || group
|
||||
? satelliteClassLabel(group, { isIss, localize: false })
|
||||
: null,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,7 +239,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
name,
|
||||
operator: '',
|
||||
noradId: String(noradId),
|
||||
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
|
||||
class: satelliteClassLabel(sat.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
localize: false,
|
||||
}),
|
||||
altitude:
|
||||
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
@@ -335,7 +336,9 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
|
||||
function unavailableReason(entry) {
|
||||
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
|
||||
return t('{name} needs the Google 3D map: choose it under MAP SOURCE', {
|
||||
name: translateKnown(entry.def.name),
|
||||
});
|
||||
}
|
||||
|
||||
/** One imagery source at a time: lighting a provider darkens the others. */
|
||||
@@ -410,7 +413,9 @@ export function createStreetLevelLayer({
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
return `zoom in to a street to open ${entry.def.name}`;
|
||||
return t('zoom in to a street to open {name}', {
|
||||
name: translateKnown(entry.def.name),
|
||||
});
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { getLocale } from '../../../../i18n/index.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
@@ -48,7 +49,7 @@ function settleWithin(promise, { ms, signal, loader }) {
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
getApiKey,
|
||||
loader = createMapsLoader({ getApiKey }),
|
||||
loader = createMapsLoader({ getApiKey, getLanguage: getLocale }),
|
||||
}) {
|
||||
if (typeof getApiKey !== 'function')
|
||||
throw new TypeError('Google Street View needs getApiKey');
|
||||
|
||||
@@ -9,10 +9,13 @@ const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
* @param {{getApiKey: () => string|null|undefined, getLanguage?: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
* `getLanguage` names the UI language; Google's own controls (Terms,
|
||||
* Report a problem) follow it. English adds nothing, as before.
|
||||
*/
|
||||
export function createMapsLoader({
|
||||
getApiKey,
|
||||
getLanguage,
|
||||
document = globalThis.document,
|
||||
global = globalThis,
|
||||
}) {
|
||||
@@ -53,12 +56,15 @@ export function createMapsLoader({
|
||||
};
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
|
||||
const params = new URLSearchParams({
|
||||
key,
|
||||
v: 'weekly',
|
||||
loading: 'async',
|
||||
callback: READY_CALLBACK,
|
||||
})}`;
|
||||
});
|
||||
const language = getLanguage?.();
|
||||
if (language && language !== 'en') params.set('language', language);
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${params}`;
|
||||
script.onerror = () => {
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
|
||||
@@ -142,3 +142,19 @@ test('a retried load still reports a refusal once, and the page handler once', a
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1);
|
||||
});
|
||||
|
||||
test('Google controls follow the UI language; English adds no parameter', async () => {
|
||||
const language = (locale) => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({
|
||||
getApiKey: () => 'k-1',
|
||||
getLanguage: () => locale,
|
||||
...page,
|
||||
});
|
||||
void loader.importLibrary('streetView').catch(() => {});
|
||||
return new URL(page.scripts[0].src).searchParams.get('language');
|
||||
};
|
||||
assert.equal(language('es'), 'es');
|
||||
assert.equal(language('ru'), 'ru');
|
||||
assert.equal(language('en'), null);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
export const GOOGLE_PROVIDER_ID = 'google';
|
||||
export const GOOGLE_NAME = 'Google Street View';
|
||||
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
|
||||
export const GOOGLE_NAME = msg('Google Street View');
|
||||
export const GOOGLE_LABEL = msg('GOOGLE STREET VIEW');
|
||||
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||
export const GOOGLE_KEY_ID = 'google-maps';
|
||||
|
||||
@@ -19,8 +20,9 @@ export const GOOGLE_CREDIT_HTML =
|
||||
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/** Shown when Google refuses the key (gm_authFailure). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
|
||||
export const KEY_REJECTED_MESSAGE = msg(
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer',
|
||||
);
|
||||
|
||||
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
@@ -29,10 +31,11 @@ export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Pause after a failed details lookup before a view event can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
|
||||
export const NO_ANSWER_MESSAGE = msg(
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY',
|
||||
);
|
||||
|
||||
export const GROUND_CLICK_HINT = 'click a street to open Street View';
|
||||
export const GROUND_CLICK_HINT = msg('click a street to open Street View');
|
||||
|
||||
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
@@ -436,11 +437,11 @@ export function createKartaViewViewer({
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
|
||||
t('KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom'),
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
picture.alt = 'KartaView street-level photo';
|
||||
picture.alt = t('KartaView street-level photo');
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
|
||||
@@ -119,7 +119,11 @@ export function composeUIState({
|
||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
||||
)
|
||||
)
|
||||
legend.push({ key: 'selected', label: t('Selected'), color: COLORS.selected });
|
||||
legend.push({
|
||||
key: 'selected',
|
||||
label: t('Selected'),
|
||||
color: COLORS.selected,
|
||||
});
|
||||
return {
|
||||
enabled,
|
||||
groundClickReady: groundClickReady === true,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createBundledCableSource({
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
label: 'TeleGeography',
|
||||
label: 'TeleGeography', // i18n-ignore: data provider name
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl(), signal),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
accentForVesselType,
|
||||
normalizeVesselType,
|
||||
displayVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
|
||||
export function createCards({
|
||||
vesselState,
|
||||
@@ -130,7 +130,7 @@ export function createCards({
|
||||
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
||||
|
||||
function vesselTypeShort(record) {
|
||||
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
|
||||
return displayVesselType(record.type).toUpperCase().slice(0, 14);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,11 +160,22 @@ export function createCards({
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
|
||||
return t('{speed}KT', {
|
||||
speed:
|
||||
speed === null
|
||||
? '--'
|
||||
: formatNumber(speed, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
useGrouping: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function formatHeading(heading) {
|
||||
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
|
||||
return t('{heading}DEG', {
|
||||
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
|
||||
});
|
||||
}
|
||||
|
||||
function formatPositionTime(record) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
||||
export function resolveImageryHost({ viewer, tileset }) {
|
||||
if (viewer?.scene?.globe?.show === true)
|
||||
@@ -7,7 +9,9 @@ export function resolveImageryHost({ viewer, tileset }) {
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
export const NO_IMAGERY_HOST = msg(
|
||||
'Hidden by this map source · choose a globe map',
|
||||
);
|
||||
|
||||
/** Report imagery suspension without changing or detaching the resolved host. */
|
||||
export function imageryHostStatus(host) {
|
||||
|
||||
+132
-53
@@ -7,6 +7,8 @@ import {
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
@@ -29,7 +31,9 @@ const LIGHTNING_STOPS = [
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
value
|
||||
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
|
||||
: t('Unavailable');
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
@@ -227,12 +231,14 @@ export function createWeatherLayer({
|
||||
rendering.setHidden?.(false);
|
||||
void warmNext(time);
|
||||
}
|
||||
error = ok ? null : 'Frame unavailable; previous observation retained';
|
||||
error = ok
|
||||
? null
|
||||
: msg('Frame unavailable; previous observation retained');
|
||||
if (!ok) stop();
|
||||
return ok;
|
||||
} catch {
|
||||
if (owner === generation && !controller.signal.aborted) {
|
||||
error = 'Weather imagery unavailable';
|
||||
error = msg('Weather imagery unavailable');
|
||||
stop();
|
||||
}
|
||||
return false;
|
||||
@@ -338,7 +344,9 @@ export function createWeatherLayer({
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (snapshot.unavailable) {
|
||||
error = 'Weather source unavailable; previous observation retained';
|
||||
error = msg(
|
||||
'Weather source unavailable; previous observation retained',
|
||||
);
|
||||
stop();
|
||||
return true;
|
||||
}
|
||||
@@ -368,7 +376,7 @@ export function createWeatherLayer({
|
||||
return !controller.signal.aborted && request === controller && enabled;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Weather unavailable';
|
||||
error = cause?.message || msg('Weather unavailable');
|
||||
stop();
|
||||
return true;
|
||||
} finally {
|
||||
@@ -487,15 +495,23 @@ export function createWeatherLayer({
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
||||
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
|
||||
? maxGap() / 60_000 < 60
|
||||
? t('No frame within {gap} min of {time}', {
|
||||
gap: maxGap() / 60_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: t('No frame within {gap} h of {time}', {
|
||||
gap: maxGap() / 3600_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: null;
|
||||
const time = shownTime();
|
||||
const relation =
|
||||
shared?.mode === 'history' && time
|
||||
? Date.parse(time) === Date.parse(shared.target)
|
||||
? ' · synced'
|
||||
? 'synced'
|
||||
: Date.parse(time) < Date.parse(shared.target)
|
||||
? ' · nearest'
|
||||
? 'nearest'
|
||||
: ''
|
||||
: '';
|
||||
const times = manifest?.times || [];
|
||||
@@ -508,9 +524,16 @@ export function createWeatherLayer({
|
||||
age === null
|
||||
? ''
|
||||
: age >= 60
|
||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
||||
: `${age}m ago`;
|
||||
? t('{hours}h {minutes}m ago', {
|
||||
hours: Math.floor(age / 60),
|
||||
minutes: age % 60,
|
||||
})
|
||||
: t('{minutes}m ago', { minutes: age });
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
// Errors are stored in English (stats, diagnostics); shown translated.
|
||||
const problem = error || diagnostic?.error;
|
||||
const shownProblem = problem ? t(problem) : null;
|
||||
const shownHost = hostStatus ? t(hostStatus) : null;
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const point =
|
||||
@@ -535,49 +558,94 @@ export function createWeatherLayer({
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
let detail = time
|
||||
? t('{state} · {time} · {lag}', {
|
||||
state: followLatest ? t('Observed') : t('History'),
|
||||
time: utc(time),
|
||||
lag,
|
||||
})
|
||||
: missing
|
||||
? t('Observation unavailable')
|
||||
: t('Waiting for observation');
|
||||
if (relation === 'synced') detail = t('{detail} · synced', { detail });
|
||||
else if (relation === 'nearest')
|
||||
detail = t('{detail} · nearest', { detail });
|
||||
const infoHeading = radar
|
||||
? t('RADAR REFLECTIVITY · dBZ')
|
||||
: lightning
|
||||
? t('LIGHTNING DENSITY · 15 min accumulation')
|
||||
: product === 'clouds'
|
||||
? t('GLOBAL INFRARED · hourly')
|
||||
: t('GOES INFRARED · ~5 min');
|
||||
const infoTime = time
|
||||
? `${t('{state}: {time}', {
|
||||
state: followLatest ? t('Latest observation') : t('History'),
|
||||
time: utc(time),
|
||||
})}\n${[
|
||||
lag,
|
||||
current && !followLatest
|
||||
? t('frame {index}/{total}', {
|
||||
index: index + 1,
|
||||
total: times.length,
|
||||
})
|
||||
: '',
|
||||
loading ? t('loading') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}`
|
||||
: [t('Observation: unavailable'), loading ? t('loading') : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const infoCoverage = radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery');
|
||||
const infoMotion = motion?.matches
|
||||
? clock
|
||||
? t('Reduced motion · history playback unavailable')
|
||||
: t('Reduced motion · manual history available')
|
||||
: '';
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: radar
|
||||
? 'Rain radar · US'
|
||||
? t('Rain radar · US')
|
||||
: lightning
|
||||
? 'Lightning density · 15 min'
|
||||
: 'Satellite clouds',
|
||||
? t('Lightning density · 15 min')
|
||||
: t('Satellite clouds'),
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? 'Americas + Pacific'
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? 'Global · 60°S–60°N'
|
||||
: 'North America',
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America'),
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail: time
|
||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
||||
: missing
|
||||
? 'Observation unavailable'
|
||||
: 'Waiting for observation',
|
||||
detail,
|
||||
status:
|
||||
missing ||
|
||||
hostStatus ||
|
||||
error ||
|
||||
diagnostic?.error ||
|
||||
shownHost ||
|
||||
shownProblem ||
|
||||
(observationDelayed()
|
||||
? 'Source observations delayed'
|
||||
? t('Source observations delayed')
|
||||
: manifest?.stale
|
||||
? 'Stale source'
|
||||
? t('Stale source')
|
||||
: loading
|
||||
? 'Loading next frame…'
|
||||
? t('Loading next frame…')
|
||||
: outside
|
||||
? 'Map center outside coverage'
|
||||
? t('Map center outside coverage')
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
||||
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
? [
|
||||
['clouds-regional', 'N. America'],
|
||||
['clouds', 'Global'],
|
||||
['clouds-regional', t('N. America')],
|
||||
['clouds', t('Global')],
|
||||
].map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
@@ -585,43 +653,44 @@ export function createWeatherLayer({
|
||||
params: { product: value },
|
||||
title:
|
||||
value === 'clouds'
|
||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
||||
: 'GOES regional clouds; approximately 5-minute updates',
|
||||
? t('Hourly global mosaic; usually 2–3 hours delayed')
|
||||
: t('GOES regional clouds; approximately 5-minute updates'),
|
||||
}))
|
||||
: []),
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'filtered',
|
||||
label: 'Clouds only',
|
||||
label: t('Clouds only'),
|
||||
active: infrared === 'filtered',
|
||||
params: { infrared: 'filtered' },
|
||||
title:
|
||||
title: t(
|
||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'full',
|
||||
label: 'Full',
|
||||
label: t('Full'),
|
||||
active: infrared === 'full',
|
||||
params: { infrared: 'full' },
|
||||
title: 'The complete infrared image at the chosen opacity',
|
||||
title: t('The complete infrared image at the chosen opacity'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...['light', 'strong'].map((value) => ({
|
||||
id: `opacity-${value}`,
|
||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
||||
label: value === 'light' ? t('Soft') : t('Vivid'),
|
||||
active: opacity === value,
|
||||
params: { opacity: value },
|
||||
title: 'Image opacity; does not alter the observed values',
|
||||
title: t('Image opacity; does not alter the observed values'),
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? 'View US radar'
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? 'View Americas & Pacific'
|
||||
: 'View coverage',
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
@@ -632,37 +701,47 @@ export function createWeatherLayer({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
||||
: `${label} dBZ radar reflectivity`,
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
? hostStatus
|
||||
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
|
||||
? shownHost
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
|
||||
infoTitle: lightning
|
||||
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
|
||||
? t(
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: radar
|
||||
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
|
||||
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
),
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'region',
|
||||
label: 'REGION',
|
||||
label: t('REGION'),
|
||||
chips: controls.chips.filter(({ params }) => params.product),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
label: t('IMAGE'),
|
||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'OPACITY',
|
||||
label: t('OPACITY'),
|
||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||
},
|
||||
];
|
||||
@@ -677,7 +756,7 @@ export function createWeatherLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? 'Observed' : 'History',
|
||||
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
@@ -294,7 +295,9 @@ function createGlobeRendering({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
lastError = msg(
|
||||
'Weather tiles unavailable · previous frame retained',
|
||||
);
|
||||
remove(frame);
|
||||
}
|
||||
frame.resolve(ok);
|
||||
@@ -413,7 +416,7 @@ function createGlobeRendering({
|
||||
return;
|
||||
}
|
||||
frame.failed = true;
|
||||
lastError = 'Some weather tiles unavailable';
|
||||
lastError = msg('Some weather tiles unavailable');
|
||||
onChange();
|
||||
});
|
||||
// A shown, transparent layer lets Cesium request staging tiles while the last
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { acquireWeatherImage } from './infraredImage.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||||
@@ -630,7 +631,7 @@ export function createWeatherShell({
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
lastError = msg('Weather tiles unavailable · previous frame retained');
|
||||
frame.resolve(false);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
|
||||
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
|
||||
+56
-41
@@ -11,6 +11,8 @@ import {
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
@@ -172,7 +174,7 @@ export function createWindLayer({
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
error = cause?.message || msg('Wind source unavailable');
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -242,7 +244,7 @@ export function createWindLayer({
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
? t('Forecast · does not follow history')
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
@@ -258,13 +260,16 @@ export function createWindLayer({
|
||||
requestedScalar() !== 'none' &&
|
||||
manifest &&
|
||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||
// Stored in English for diagnostics; shown translated.
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
enabled && !loading && diagnostic?.imageryError
|
||||
? t(diagnostic.imageryError)
|
||||
: null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
none: t('Wind motion'),
|
||||
speed: t('Wind speed'),
|
||||
pressure: t('Sea-level pressure'),
|
||||
temperature: t('Air temperature · 2 m'),
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
@@ -290,60 +295,64 @@ export function createWindLayer({
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
coverage: t('Global · 1° grid'),
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
detail: t('{model} forecast · {time}', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
time: valid || t('Unavailable'),
|
||||
}),
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
? t('Loading forecast')
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
: error ||
|
||||
? t('Preparing flow')
|
||||
: (error && t(error)) ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
(scalarMissing ? t('Selected field unavailable') : null) ||
|
||||
(manifest?.stale ? t('Cached forecast · stale') : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
? t('ECMWF IFS surface forecast')
|
||||
: t('NOAA GFS surface forecast'),
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
? t('Reduced motion')
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
? t('Resume')
|
||||
: t('Pause'),
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
title: t(
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
),
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
none: t('None'),
|
||||
speed: t('Speed'),
|
||||
pressure: t('Pressure'),
|
||||
temperature: t('Temperature'),
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
none: t('Wind trails with no field shading'),
|
||||
speed: t('How hard the surface wind is blowing'),
|
||||
pressure: t('Sea-level air pressure: broad highs and lows'),
|
||||
temperature: t('Air temperature two meters above the surface'),
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
@@ -353,18 +362,19 @@ export function createWindLayer({
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
label: unitLabel(value),
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
label: t('Read wind at map center'),
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
title: t(
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
),
|
||||
},
|
||||
],
|
||||
legend:
|
||||
@@ -373,9 +383,14 @@ export function createWindLayer({
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
info: `${t('{model} forecast · {field} ({unit})', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
field: label,
|
||||
unit: unitLabel(legendUnit),
|
||||
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? `\n${t(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
|
||||
infoTitle: t(
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
),
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
@@ -385,12 +400,12 @@ export function createWindLayer({
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
setting('model', t('MODEL'), 'model-'),
|
||||
setting('field', t('FIELD'), 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
? [{ id: 'units', label: t('UNITS'), chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
setting('motion', t('MOTION'), 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
@@ -425,7 +440,7 @@ export function createWindLayer({
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
countLabel: t('Forecast'),
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
@@ -433,7 +448,7 @@ export function createWindLayer({
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
// The reading stays English: AI tools read it too. Text is marked for i18n
|
||||
// extraction and translated where the WEATHER card shows it (presentation.js).
|
||||
const msg = (text) => text;
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
msg('N'),
|
||||
msg('NNE'),
|
||||
msg('NE'),
|
||||
msg('ENE'),
|
||||
msg('E'),
|
||||
msg('ESE'),
|
||||
msg('SE'),
|
||||
msg('SSE'),
|
||||
msg('S'),
|
||||
msg('SSW'),
|
||||
msg('SW'),
|
||||
msg('WSW'),
|
||||
msg('W'),
|
||||
msg('WNW'),
|
||||
msg('NW'),
|
||||
msg('NNW'),
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
@@ -56,13 +60,14 @@ export function inspectWindAtCenter(
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
coordinates: msg('No surface reading'),
|
||||
wind: msg('Aim the map center at Earth'),
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
explanation: msg(
|
||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||
),
|
||||
};
|
||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||
const lon = cesium.Math.toDegrees(location.longitude);
|
||||
@@ -87,11 +92,11 @@ export function inspectWindAtCenter(
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
? msg('Wind speed')
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
? msg('Air temperature · 2 m')
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
? msg('Sea-level pressure')
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
@@ -102,8 +107,9 @@ export function inspectWindAtCenter(
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
explanation: msg(
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
|
||||
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
@@ -19,36 +21,111 @@ export function formatWindReading(reading, units) {
|
||||
export function windUnitChips(units) {
|
||||
return Object.keys(WIND_UNITS).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
label: unitLabel(value),
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
}));
|
||||
}
|
||||
|
||||
/** formatWindSpeed's value in the active language: "22,0 км/ч". */
|
||||
function displaySpeed(speed, units) {
|
||||
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
|
||||
return formatQuantity(speed * WIND_UNITS[unit], unit, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** "1012.4 hPa" / "-3.0 °C" from the English reading, in the active language. */
|
||||
function displayScalar(reading) {
|
||||
if (reading.scalarKind === 'speed' && Number.isFinite(reading.speed))
|
||||
return displaySpeed(reading.speed, reading.units);
|
||||
const match = /^(-?\d+(?:\.\d+)?) (°C|hPa)$/.exec(reading.scalarValue);
|
||||
if (!match) return reading.scalarValue;
|
||||
const decimals = match[1].split('.')[1]?.length || 0;
|
||||
return formatQuantity(Number(match[1]), match[2], {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
}
|
||||
|
||||
/** The wind line in the active language; the reading itself stays English. */
|
||||
function windText(reading) {
|
||||
if (!Number.isFinite(reading.speed))
|
||||
return reading.wind === 'Aim the map center at Earth'
|
||||
? t('Aim the map center at Earth')
|
||||
: reading.wind;
|
||||
const speed = displaySpeed(reading.speed, reading.units);
|
||||
return reading.from === 'Calm'
|
||||
? `${speed}${t(' · calm')}`
|
||||
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
|
||||
}
|
||||
|
||||
/** "29.93°N · 97.74°W" from the English reading, in the active language. */
|
||||
function displayCoordinates(coordinates) {
|
||||
const match = /^(\d+(?:\.\d+)?)°([NS]) · (\d+(?:\.\d+)?)°([EW])$/.exec(
|
||||
coordinates,
|
||||
);
|
||||
if (!match) return coordinates.replace(' · ', ' ');
|
||||
const part = (value, hemisphere) => {
|
||||
const decimals = value.split('.')[1]?.length || 0;
|
||||
const amount = formatNumber(Number(value), {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: false,
|
||||
});
|
||||
switch (hemisphere) {
|
||||
case 'N':
|
||||
return t('{value}°N', { value: amount });
|
||||
case 'S':
|
||||
return t('{value}°S', { value: amount });
|
||||
case 'E':
|
||||
return t('{value}°E', { value: amount });
|
||||
default:
|
||||
return t('{value}°W', { value: amount });
|
||||
}
|
||||
};
|
||||
return `${part(match[1], match[2])} ${part(match[3], match[4])}`;
|
||||
}
|
||||
|
||||
/** "2026-10-14 14:00 UTC" from the reading, as "10-14 14:00 UTC" / "14.10 …". */
|
||||
function displayValidTime(validTime) {
|
||||
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) UTC$/.exec(validTime || '');
|
||||
if (!match) return validTime?.replace(/^\d{4}-/, '');
|
||||
return `${formatUtcDayMonth(match[1])} ${match[2]} UTC`;
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
const coordinates =
|
||||
reading.coordinates === 'No surface reading'
|
||||
? t('No surface reading')
|
||||
: displayCoordinates(reading.coordinates);
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
label: t('WIND AT {coordinates}', { coordinates }),
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
text: windText(reading),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
text: t('{model} · valid {time}', {
|
||||
model: reading.model,
|
||||
time: displayValidTime(reading.validTime),
|
||||
}),
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
text: `${t(reading.scalarLabel)} · ${displayScalar(reading)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
{ id: 'explanation', text: t(reading.explanation) },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
|
||||
@@ -2,7 +2,16 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
||||
|
||||
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
|
||||
const sample = {
|
||||
speed: 5,
|
||||
from: 'SW',
|
||||
coordinates: '41.9°N · 87.6°W',
|
||||
model: 'NOAA GFS',
|
||||
validTime: '2026-09-21 12:00 UTC',
|
||||
scalarLabel: 'Air temperature · 2 m',
|
||||
scalarValue: '12 °C',
|
||||
explanation: 'Interpolated model forecast.',
|
||||
};
|
||||
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||
const reading = formatWindReading(sample, 'mph');
|
||||
assert.equal(reading.wind, '11.2 mph from SW');
|
||||
@@ -11,12 +20,30 @@ test('captured reading reformats without changing its sample and produces a port
|
||||
assert.equal(sample.units, undefined);
|
||||
const section = windReadingResult(reading);
|
||||
assert.equal(section.id, 'reading');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'scalar').text,
|
||||
'Air temperature · 2 m · 12\u00a0°C',
|
||||
);
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'wind').text,
|
||||
'11.2\u00a0mph from SW',
|
||||
);
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'meta').text,
|
||||
'NOAA GFS · valid 09-21 12:00 UTC',
|
||||
);
|
||||
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||
assert.deepEqual(section.clear.params, { inspect: false });
|
||||
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
|
||||
assert.equal(
|
||||
formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind,
|
||||
'5.0 m/s · calm',
|
||||
);
|
||||
assert.equal(formatWindReading(null, 'km/h'), null);
|
||||
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
|
||||
assert.equal(
|
||||
windReadingResult({
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Unavailable',
|
||||
}).lines.some(({ id }) => id === 'scalar'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { createWindRelief } from './relief.js';
|
||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
@@ -344,7 +345,7 @@ export function createWindRendering({
|
||||
? !cesium.Primitive
|
||||
: !collection || !cesium.SingleTileImageryProvider
|
||||
) {
|
||||
imageryError = 'Globe imagery unavailable';
|
||||
imageryError = msg('Globe imagery unavailable');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -384,12 +385,12 @@ export function createWindRendering({
|
||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||
imageryError = 'Globe field image unavailable';
|
||||
imageryError = msg('Globe field image unavailable');
|
||||
});
|
||||
viewer.scene?.requestRender?.();
|
||||
} catch {
|
||||
removeImagery();
|
||||
imageryError = 'Globe field image unavailable';
|
||||
imageryError = msg('Globe field image unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ export function reduceTrafficSyncFeedback(
|
||||
// Neutral default: the layer always supplies its own LIVE/SIMULATED
|
||||
// label, and a fallback string must never claim a live feed on a
|
||||
// keyless build.
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: label || 'syncing road network',
|
||||
progressText: hasProgress ? `${progressPct}%` : '...',
|
||||
};
|
||||
@@ -392,6 +393,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
);
|
||||
return {
|
||||
state: 'retry',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: (
|
||||
camera.cameraRetry.error || 'Overpass temporarily unavailable'
|
||||
).toUpperCase(),
|
||||
@@ -438,6 +440,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
|
||||
return {
|
||||
state: 'loading',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: active[0].cameraRetry.retrying
|
||||
? 'RETRYING ALPR CAMERAS'
|
||||
: 'FETCHING ALPR CAMERAS',
|
||||
@@ -452,6 +455,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
) {
|
||||
return {
|
||||
state: 'loading',
|
||||
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
||||
label: active[0].installationRetry.retrying
|
||||
? 'RETRYING MAPPED SITES'
|
||||
: 'FETCHING MAPPED SITES',
|
||||
|
||||
@@ -26,7 +26,7 @@ test('the ACTIVE STYLE indicator is written from the style name and nothing else
|
||||
assert.equal(writes.length, 1, 'the style indicator must have exactly one writer');
|
||||
assert.match(
|
||||
ui.slice(writes[0].index, writes[0].index + 160),
|
||||
/this\._styleIndicator\.textContent =\s*displayNames\[styleName\] \|\| styleName\.toUpperCase\(\);/,
|
||||
/this\._styleIndicator\.textContent = STYLE_STATUS_LABELS\[styleName\]\s*\?\s*t\(STYLE_STATUS_LABELS\[styleName\]\)\s*:\s*styleName\.toUpperCase\(\);/,
|
||||
);
|
||||
|
||||
const handler = locationSearchHandler();
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { createStandaloneApplication } from './standalone/application.js';
|
||||
import { describeError } from './standalone/errors.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
const application = createStandaloneApplication({
|
||||
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
|
||||
@@ -10,7 +11,9 @@ const application = createStandaloneApplication({
|
||||
application.start().catch((error) => {
|
||||
console.error("God's Eye View initialization failed:", error);
|
||||
const loaderStatus = document.querySelector('#loading-screen .loader-status');
|
||||
loaderStatus.textContent = `Error: ${describeError(error)}`;
|
||||
loaderStatus.textContent = t('Error: {message}', {
|
||||
message: describeError(error),
|
||||
});
|
||||
loaderStatus.style.color = '#ff4444';
|
||||
});
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><\/div>/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
|
||||
@@ -14,7 +14,12 @@ import { t } from '../i18n/index.js';
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
return t(
|
||||
"Google 3D tiles unavailable — check the key's API restrictions, quota, or network",
|
||||
);
|
||||
const envVars = keySetupRequirementEnvVars('google-maps');
|
||||
return `${t('Needs {envVars} — add it in Provider Settings', { envVars })} — or a Cesium ion token for the ion-hosted route`;
|
||||
return t(
|
||||
'Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route',
|
||||
{ envVars },
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -2,14 +2,14 @@ import * as Cesium from 'cesium';
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
label: 'Google 3D', // i18n-ignore: imagery product name
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
label: 'Bing Aerial', // i18n-ignore: imagery product name
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
@@ -17,7 +17,7 @@ export const MAP_STACKS = [
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
label: 'Bing Labels', // i18n-ignore: imagery product name
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
@@ -25,14 +25,14 @@ export const MAP_STACKS = [
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
label: 'Esri Satellite', // i18n-ignore: imagery product name
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
label: 'OSM', // i18n-ignore: imagery product name
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
*/
|
||||
|
||||
/** Guidance shown when the active map stack cannot host draped imagery. */
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
// Marks text for i18n extraction without importing i18n here; the UI
|
||||
// translates it where it is shown.
|
||||
const msg = (text) => text;
|
||||
|
||||
export const NO_IMAGERY_HOST = msg(
|
||||
'Hidden by this map source · choose a globe map',
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the imagery layer collection for the active map stack.
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
placementVariants,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* @module worldOverlay
|
||||
@@ -1269,7 +1270,7 @@ function ensureOverlayDom() {
|
||||
_accessibilityRoot.id = ACCESSIBILITY_ROOT_ID;
|
||||
_accessibilityRoot.className = 'world-overlay-accessibility';
|
||||
_accessibilityRoot.setAttribute('role', 'region');
|
||||
_accessibilityRoot.setAttribute('aria-label', 'Visible map targets');
|
||||
_accessibilityRoot.setAttribute('aria-label', t('Visible map targets'));
|
||||
document.body.appendChild(_accessibilityRoot);
|
||||
}
|
||||
_accessibilityList = document.getElementById(ACCESSIBILITY_LIST_ID);
|
||||
@@ -2419,7 +2420,9 @@ function syncAccessibleActions() {
|
||||
if (!activate) return;
|
||||
const accepted = activate();
|
||||
if (accepted !== false && _accessibilityStatus) {
|
||||
_accessibilityStatus.textContent = `Focusing ${item.label}`;
|
||||
_accessibilityStatus.textContent = t('Focusing {label}', {
|
||||
label: item.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
_accessibilityList.appendChild(button);
|
||||
|
||||
@@ -351,6 +351,7 @@ function buildWorkload(count) {
|
||||
id: `mover-${index}`,
|
||||
position: () => position,
|
||||
variant: 'label',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `TRK ${1000 + index}`,
|
||||
details: [`ALT ${10_000 + index * 25}`],
|
||||
priority: index % 7,
|
||||
@@ -461,6 +462,7 @@ function buildPhase3FirmsWorkload(count) {
|
||||
position,
|
||||
gapPx: 10,
|
||||
accent: '224, 82, 82',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `▲ ${50 + fireIndex} MW`,
|
||||
details: ['high · 2h · N20'],
|
||||
selected: false,
|
||||
@@ -551,6 +553,7 @@ function buildPhase3VesselsWorkload(count) {
|
||||
position,
|
||||
gapPx: 10,
|
||||
accent: '224, 82, 82',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: `▲ ${50 + fireIndex} MW`,
|
||||
details: ['high · 2h · N20'],
|
||||
selected: false,
|
||||
@@ -570,6 +573,7 @@ function buildPhase3VesselsWorkload(count) {
|
||||
position,
|
||||
gapPx: selected ? 12 : 10,
|
||||
accent: '57, 213, 255',
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: selected ? 'SELECTED VESSEL' : `VESSEL ${vesselIndex}`,
|
||||
details: selected
|
||||
? ['CARGO · 14.5KT · 231°', 'MMSI 353136000 · POS: LIVE']
|
||||
@@ -644,6 +648,7 @@ function buildPhase3TrackedWorkload(count) {
|
||||
gevTrackedId: 'flights:allocation-probe',
|
||||
gevDisplayPosition: selected.position,
|
||||
gevLabelModel: {
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: 'ALLOC01',
|
||||
details: ['FL350 · 451 kts', 'TEST AIR · A320'],
|
||||
accent: '#39d0ff',
|
||||
@@ -694,6 +699,7 @@ function buildPhase4CctvWorkload(count) {
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: active ? 'cctv:active' : `cctv:${index}`,
|
||||
position,
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
title: active ? 'ACTIVE CAMERA' : `CAMERA ${index}`,
|
||||
frameSlot: slot,
|
||||
rank: index,
|
||||
@@ -867,6 +873,7 @@ function buildPhase5CivilWorkload(count) {
|
||||
const entry = registration?.entries[0];
|
||||
if (!entry)
|
||||
throw new Error('phase5-civil requires the protected tracked entry');
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
entry.title = 'ALLOC01 · FL350 · 451 kts';
|
||||
entry.details = ['TEST AIR · A320', 'AUS → LAX'];
|
||||
return workload;
|
||||
@@ -881,6 +888,7 @@ function buildPhase5MilitaryWorkload(count) {
|
||||
if (!entry)
|
||||
throw new Error('phase5-military requires the protected tracked entry');
|
||||
entry.id = 'military:allocation-probe';
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
entry.title = 'RCH451';
|
||||
entry.details = ['C17 · 05-8152', 'USAF · 28000 ft · 400 kt'];
|
||||
entry.accent = '#ffd166';
|
||||
@@ -1067,6 +1075,7 @@ function appendSubmarineCableWorkload(workload, count) {
|
||||
id: `${kind}-reference-${index}-alloc`,
|
||||
kind,
|
||||
label:
|
||||
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||
kind === 'cable' ? `Cable System ${index}` : `Landing Point ${index}`,
|
||||
tip: position,
|
||||
distanceM: 200_000 + index * 40_000,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { renderPackGeometry } from './geometry.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function caption(viewer, pack) {
|
||||
const card = document.createElement('section');
|
||||
@@ -18,7 +19,7 @@ function caption(viewer, pack) {
|
||||
card.append(title);
|
||||
if (pack.attribution.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.textContent = t('Source');
|
||||
link.href = pack.attribution.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
+66
-45
@@ -23,10 +23,12 @@ import * as Cesium from 'cesium';
|
||||
import {
|
||||
SCENE_APPEND_RECIPES,
|
||||
SCENE_RECIPES,
|
||||
displaySceneTitle,
|
||||
displayShotTitle,
|
||||
getSceneAppendRecipeById,
|
||||
} from './recipes.js';
|
||||
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createSceneDataPacks } from './dataPacks/controller.js';
|
||||
import { createSceneInteractions } from './interactions.js';
|
||||
import { createSceneSharing } from './sharing.js';
|
||||
@@ -66,26 +68,7 @@ import {
|
||||
// project titles, and voice-matching titles all stay English (keep-English
|
||||
// boundary + persisted data); only the presentation of an UNRENAMED built-in
|
||||
// localizes, so an operator's renamed scene is never masked by the catalog.
|
||||
const RECIPE_TITLE_KEYS = Object.freeze({
|
||||
'flights-radar': msg('Global Flights Radar'),
|
||||
'orbital-watch': msg('Orbital Watch'),
|
||||
'thermal-threats': msg('Thermal Threat Board'),
|
||||
'city-overload': msg('City Overload'),
|
||||
'omniscience-pullback': msg('Omniscience Pullback'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Display title for a scene: the localized recipe name while the stored title
|
||||
* still matches the built-in verbatim, the stored title otherwise.
|
||||
* @param {{id?: string, title?: string}} scene
|
||||
* @returns {string}
|
||||
*/
|
||||
function displaySceneTitle(scene) {
|
||||
const key = RECIPE_TITLE_KEYS[scene?.id];
|
||||
if (!key) return scene?.title || '';
|
||||
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene.id);
|
||||
return scene.title === builtin?.title ? t(key) : scene.title;
|
||||
}
|
||||
// Recipe and built-in shot titles are marked with msg() in recipes.js.
|
||||
|
||||
/** @constant {string} localStorage key for the serialized project */
|
||||
const STORAGE_KEY = 'godsEyeView.sceneProject.v2';
|
||||
@@ -397,8 +380,8 @@ export class SceneDirector {
|
||||
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
|
||||
if (type === 'shot-loaded')
|
||||
this._presentation.status = t('Loaded: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
});
|
||||
this._publish({
|
||||
type,
|
||||
@@ -419,10 +402,15 @@ export class SceneDirector {
|
||||
subscribe: (listener) => this.subscribe(listener),
|
||||
read: () => ({
|
||||
// Scene titles surface in the panel through SceneControls; built-in
|
||||
// recipe titles display their localized name until renamed by the user.
|
||||
// recipe and shot titles display their localized name until renamed
|
||||
// by the user. Shots keep the stored title for editing and matching.
|
||||
scenes: this._project.scenes.map((scene) => ({
|
||||
...scene,
|
||||
title: displaySceneTitle(scene),
|
||||
shots: scene.shots.map((shot) => ({
|
||||
...shot,
|
||||
displayTitle: displayShotTitle(shot.title),
|
||||
})),
|
||||
})),
|
||||
selectedSceneId: this._selectedSceneId,
|
||||
selectedShotId: this._selectedShotId,
|
||||
@@ -519,6 +507,7 @@ export class SceneDirector {
|
||||
|
||||
const scene = {
|
||||
id: uid('scene'),
|
||||
// i18n-ignore: stored English, like normalizeProject; displaySceneTitle translates it.
|
||||
title: sceneName.trim() || `Scene ${this._project.scenes.length + 1}`,
|
||||
shots: [],
|
||||
};
|
||||
@@ -797,7 +786,9 @@ export class SceneDirector {
|
||||
));
|
||||
if (!inventoryMatches) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: shot inventory changed`,
|
||||
t('Cannot update {scene}: shot inventory changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -828,7 +819,9 @@ export class SceneDirector {
|
||||
);
|
||||
if (!sourcePackMatches) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: evidence beats changed`,
|
||||
t('Cannot update {scene}: evidence beats changed', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -851,7 +844,9 @@ export class SceneDirector {
|
||||
}));
|
||||
if (resolvedPatches.some(({ shot }) => !shot)) {
|
||||
this._updateStatus(
|
||||
`Cannot update ${recipe.title}: shot bindings are incomplete`,
|
||||
t('Cannot update {scene}: shot bindings are incomplete', {
|
||||
scene: t(recipe.title),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
appended: false,
|
||||
@@ -931,8 +926,18 @@ export class SceneDirector {
|
||||
if (announce) {
|
||||
this._updateStatus(
|
||||
marker
|
||||
? `Updated ${patchedShotCount} ${patchedShotCount === 1 ? 'shot' : 'shots'}: ${recipe.title}`
|
||||
: `Appended ${appendedShots.length} shots: ${recipe.title}`,
|
||||
? tn(
|
||||
patchedShotCount,
|
||||
'Updated {count} shot: {scene}',
|
||||
'Updated {count} shots: {scene}',
|
||||
{ scene: t(recipe.title) },
|
||||
)
|
||||
: tn(
|
||||
appendedShots.length,
|
||||
'Appended {count} shot: {scene}',
|
||||
'Appended {count} shots: {scene}',
|
||||
{ scene: t(recipe.title) },
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -961,9 +966,8 @@ export class SceneDirector {
|
||||
const shot = normalizeShot(
|
||||
{
|
||||
id: uid('shot'),
|
||||
title: t('Shot {n}', {
|
||||
n: scene.shots.length + 1,
|
||||
}),
|
||||
// i18n-ignore: stored English; displayShotTitle translates it when shown.
|
||||
title: `Shot ${scene.shots.length + 1}`,
|
||||
durationSec: DEFAULT_SHOT_DURATION_SEC,
|
||||
holdSec: DEFAULT_HOLD_SEC,
|
||||
camera,
|
||||
@@ -979,8 +983,8 @@ export class SceneDirector {
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
this._updateStatus(
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1012,8 +1016,8 @@ export class SceneDirector {
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
this._updateStatus(
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1121,7 +1125,11 @@ export class SceneDirector {
|
||||
const released = await this._releaseSceneLayers(previousScene, token);
|
||||
if (!released || token.cancelled) {
|
||||
if (!token.cancelled)
|
||||
this._updateStatus(`Could not leave scene: ${previousScene.title}`);
|
||||
this._updateStatus(
|
||||
t('Could not leave scene: {scene}', {
|
||||
scene: displaySceneTitle(previousScene),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._loadedSceneId = null;
|
||||
@@ -1171,7 +1179,12 @@ export class SceneDirector {
|
||||
seeking: true,
|
||||
});
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
}),
|
||||
);
|
||||
this._activateInteractions(scene, shot);
|
||||
return { started: true, shotId };
|
||||
}
|
||||
@@ -1533,7 +1546,12 @@ export class SceneDirector {
|
||||
running: false,
|
||||
seeking: true,
|
||||
});
|
||||
this._updateStatus(`Seeked: ${scene.title} / ${shot.title}`);
|
||||
this._updateStatus(
|
||||
t('Seeked: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1807,6 +1825,7 @@ export class SceneDirector {
|
||||
// Initialize telemetry accumulator for this run
|
||||
this._activeRun = {
|
||||
recipeId: `project-${PROJECT_VERSION}`,
|
||||
// i18n-ignore: run telemetry record, downloaded as JSON
|
||||
title: 'Editable Scene Run',
|
||||
startedAt: new Date().toISOString(),
|
||||
estimatedDurationSec,
|
||||
@@ -1950,7 +1969,7 @@ export class SceneDirector {
|
||||
if (action.type === 'shot') {
|
||||
if (this._interactionTransitions >= 64) {
|
||||
this._updateStatus(
|
||||
'Scene transition limit reached — load a shot to reset',
|
||||
t('Scene transition limit reached — load a shot to reset'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -1993,7 +2012,7 @@ export class SceneDirector {
|
||||
try {
|
||||
payload = stringifySceneDocument(this._project);
|
||||
} catch (error) {
|
||||
this._updateStatus(`Export failed: ${error.message}`);
|
||||
this._updateStatus(t('Export failed: {error}', { error: error.message }));
|
||||
return;
|
||||
}
|
||||
// Trigger a browser download via a temporary anchor element
|
||||
@@ -2066,7 +2085,7 @@ export class SceneDirector {
|
||||
this._loadedSceneId = null;
|
||||
this._saveProject();
|
||||
this._publish({ type: 'project-imported', project });
|
||||
this._updateStatus(`Imported ${file.name}`);
|
||||
this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -2077,8 +2096,8 @@ export class SceneDirector {
|
||||
return false;
|
||||
this._updateStatus(
|
||||
error instanceof SceneDocumentError
|
||||
? `Import failed: ${error.message}`
|
||||
: 'Import failed (could not read JSON file)',
|
||||
? t('Import failed: {error}', { error: error.message })
|
||||
: t('Import failed (could not read JSON file)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2178,7 +2197,9 @@ export class SceneDirector {
|
||||
}
|
||||
|
||||
if (refused.length) {
|
||||
this._updateStatus(`Layers refused: ${refused.join(', ')}`);
|
||||
this._updateStatus(
|
||||
t('Layers refused: {layers}', { layers: refused.join(', ') }),
|
||||
);
|
||||
this._logEvent('shot_layers_refused', { layerIds: [...refused] });
|
||||
}
|
||||
return { applied, refused, cancelled: false };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
import { createInteractionSession } from '../director/interactions/session.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Own the active shot's pick handler, accessible actions and plain-text feedback. */
|
||||
export function createSceneInteractions(
|
||||
@@ -24,7 +25,7 @@ export function createSceneInteractions(
|
||||
card.append(text);
|
||||
if (item.action.url) {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = 'Source';
|
||||
link.textContent = t('Source');
|
||||
link.style.color = '#6eeaff';
|
||||
link.href = item.action.url;
|
||||
link.target = '_blank';
|
||||
@@ -60,8 +61,8 @@ export function createSceneInteractions(
|
||||
const ok = await session.dispatch(id);
|
||||
if (current && current === panel && status)
|
||||
status.textContent = ok
|
||||
? 'Action complete'
|
||||
: 'Action unavailable or cancelled';
|
||||
? t('Action complete')
|
||||
: t('Action unavailable or cancelled');
|
||||
return ok;
|
||||
}
|
||||
return {
|
||||
@@ -87,7 +88,7 @@ export function createSceneInteractions(
|
||||
}
|
||||
panel = document.createElement('section');
|
||||
panel.dataset.directorInteractions = '';
|
||||
panel.setAttribute('aria-label', 'Scene actions');
|
||||
panel.setAttribute('aria-label', t('Scene actions'));
|
||||
Object.assign(panel.style, {
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
@@ -104,12 +105,13 @@ export function createSceneInteractions(
|
||||
});
|
||||
const owner = panel;
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Scene actions';
|
||||
title.textContent = t('Scene actions');
|
||||
panel.append(title);
|
||||
status = document.createElement('p');
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent =
|
||||
'Select a feature or use Tab and Enter to choose an action.';
|
||||
status.textContent = t(
|
||||
'Select a feature or use Tab and Enter to choose an action.',
|
||||
);
|
||||
panel.append(status);
|
||||
for (const item of items) {
|
||||
const button = document.createElement('button');
|
||||
@@ -157,7 +159,12 @@ export function createSceneInteractions(
|
||||
? '2px solid #6eeaff'
|
||||
: '';
|
||||
}
|
||||
status.textContent = `Selected feature: ${matches[0].target.featureId}. Choose an action.`;
|
||||
status.textContent = t(
|
||||
'Selected feature: {feature}. Choose an action.',
|
||||
{
|
||||
feature: matches[0].target.featureId,
|
||||
},
|
||||
);
|
||||
buttons.get(matches[0].id)?.focus({ preventScroll: true });
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SOURCE_PATH_BEAT_IDS } from '../data/bhoteKoshiShotPaths.js';
|
||||
const ADDITIONAL_SHOTS = [
|
||||
{
|
||||
id: 'second-landslide',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Second landslide',
|
||||
lat: 28.399234,
|
||||
lon: 85.535806,
|
||||
@@ -17,6 +18,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dhunche',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dhunche',
|
||||
lat: 28.161431,
|
||||
lon: 85.324278,
|
||||
@@ -27,6 +29,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'mailung-upper-trishuli',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Mailung / Upper Trishuli bridge',
|
||||
lat: 28.140642,
|
||||
lon: 85.265996,
|
||||
@@ -37,6 +40,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'mailung-bazzar',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Mailung Bazzar, Dandaguan',
|
||||
lat: 28.126526,
|
||||
lon: 85.248027,
|
||||
@@ -47,6 +51,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dandagaun',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dandagaun, Rasuwa',
|
||||
lat: 28.124424,
|
||||
lon: 85.245154,
|
||||
@@ -57,6 +62,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'dandagaun-viewpoint',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Dandagaun viewpoint restaurant',
|
||||
lat: 28.086663,
|
||||
lon: 85.229914,
|
||||
@@ -67,6 +73,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'betrawati-bazaar',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Betrawati Bazaar',
|
||||
lat: 28.029827,
|
||||
lon: 85.227694,
|
||||
@@ -77,6 +84,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'bhainse',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Bhainse',
|
||||
lat: 28.023087,
|
||||
lon: 85.229449,
|
||||
@@ -87,6 +95,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'devighat-taadi-khola-bridge',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Devighat / Taadi Khola Bridge',
|
||||
lat: 27.921132,
|
||||
lon: 85.177113,
|
||||
@@ -97,6 +106,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'charaudi',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Charaudi',
|
||||
lat: 27.865095,
|
||||
lon: 84.838424,
|
||||
@@ -107,6 +117,7 @@ const ADDITIONAL_SHOTS = [
|
||||
},
|
||||
{
|
||||
id: 'final-view',
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Final view',
|
||||
lat: 28.0834,
|
||||
lon: 85.206,
|
||||
@@ -340,6 +351,7 @@ export function expandNepalEvidencePack(base) {
|
||||
shotPatches: [
|
||||
...(base.shotPatches || []),
|
||||
{
|
||||
// i18n-ignore: shot title (mostly a place name), matched by title on upgrade
|
||||
title: 'Final view',
|
||||
layers: finalView.layers,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Connect the portable Director runner to the existing scene presentation. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { displaySceneTitle, displayShotTitle } from './recipes.js';
|
||||
|
||||
export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
let timing;
|
||||
@@ -19,8 +20,8 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
|
||||
t('Running {index}/{total}: {scene} / {shot}', {
|
||||
index: index + 1,
|
||||
total,
|
||||
scene: scene.title,
|
||||
shot: shot.title,
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title),
|
||||
}),
|
||||
);
|
||||
director._logEvent('shot_start', {
|
||||
|
||||
@@ -122,6 +122,7 @@ export function recipeToScene(recipe) {
|
||||
const mapStack = keyframe.mapStack || post.mapStack;
|
||||
return {
|
||||
id: uid('shot'),
|
||||
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||
title: keyframe.title || `Shot ${idx + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
@@ -171,6 +172,7 @@ export function recipeToScene(recipe) {
|
||||
|
||||
return {
|
||||
id: recipe.id || uid('scene'),
|
||||
// i18n-ignore: persisted fallback; built-in titles display through director
|
||||
title: recipe.title || 'Untitled Scene',
|
||||
releaseLayerIds: [
|
||||
...new Set(
|
||||
@@ -225,6 +227,7 @@ export function normalizeShot(
|
||||
|
||||
return {
|
||||
id: rawShot?.id || uid('shot'),
|
||||
// i18n-ignore: persisted fallback; legacy bootstrap matches 'Shot N'
|
||||
title: rawShot?.title || `Shot ${index + 1}`,
|
||||
durationSec: Math.max(
|
||||
0.2,
|
||||
@@ -349,6 +352,7 @@ export function normalizeProject(rawProject) {
|
||||
);
|
||||
return {
|
||||
id: scene?.id || uid('scene'),
|
||||
// i18n-ignore: persisted fallback for a saved scene without a title
|
||||
title: scene?.title || `Scene ${sceneIdx + 1}`,
|
||||
...(scene?.dataPacks ? { dataPacks: deepClone(scene.dataPacks) } : {}),
|
||||
...(scene?.anchors ? { anchors: deepClone(scene.anchors) } : {}),
|
||||
|
||||
+88
-7
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { expandNepalEvidencePack } from './nepalEvidencePack.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 12,
|
||||
title: 'Bhote Koshi Evidence Sequence',
|
||||
title: msg('Bhote Koshi Evidence Sequence'),
|
||||
style: 'normal',
|
||||
ui: { hidePanels: false, hudMode: 'full', safeFrame: '16:9' },
|
||||
layers: {
|
||||
@@ -26,6 +27,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
fromShotTitles: ['Shot 1', 'Shot 2', 'Shot 3'],
|
||||
cameraPath: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Global Incident Context',
|
||||
lat: 27.5962,
|
||||
lon: 78.5718,
|
||||
@@ -43,6 +45,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
lat: 28.7248,
|
||||
lon: 81.3226,
|
||||
@@ -60,6 +63,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
lat: 27.0354,
|
||||
lon: 84.6287,
|
||||
@@ -77,6 +81,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
lat: 27.891,
|
||||
lon: 85.428,
|
||||
@@ -94,6 +99,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
lat: 28.0529,
|
||||
lon: 85.2189,
|
||||
@@ -111,6 +117,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
lat: 28.1691,
|
||||
lon: 85.352,
|
||||
@@ -128,6 +135,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
lat: 27.8986,
|
||||
lon: 84.8723,
|
||||
@@ -145,6 +153,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
lat: 28.4187,
|
||||
lon: 85.4341,
|
||||
@@ -221,6 +230,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
shotPatches: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Global Incident Context',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -236,6 +246,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-Focused Globe Rotation',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -251,6 +262,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Regional Approach',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -266,6 +278,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Nearby Cities',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -281,6 +294,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Incident Corridor',
|
||||
camera: {
|
||||
lat: 28.0529,
|
||||
@@ -305,6 +319,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Flood Path',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -320,6 +335,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Corridor Overview',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
layers: {
|
||||
@@ -335,6 +351,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bhote Koshi Upper Valley',
|
||||
holdSec: 8,
|
||||
visual: { mapStack: 'photoreal' },
|
||||
@@ -357,6 +374,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Upper Valley Collapse',
|
||||
camera: {
|
||||
lat: 28.417,
|
||||
@@ -380,24 +398,29 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-China Border Gate',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Timure Evidence Cluster',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Syabru Besi Passage',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
visual: { mapStack: 'photoreal' },
|
||||
},
|
||||
],
|
||||
cameraPath: [
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Upper Valley Collapse',
|
||||
lat: 28.417,
|
||||
lon: 85.3937,
|
||||
@@ -421,6 +444,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Debris-Dammed Lake',
|
||||
lat: 28.392914,
|
||||
lon: 85.570921,
|
||||
@@ -444,6 +468,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Nepal-China Border Gate',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.316789,
|
||||
@@ -474,6 +499,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Timure Evidence Cluster',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.285968,
|
||||
@@ -502,6 +528,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Syabru Besi Passage',
|
||||
mapStack: 'photoreal',
|
||||
lat: 28.210306,
|
||||
@@ -530,6 +557,7 @@ const BHOTE_KOSHI_NEPAL_BASE_RECIPE = Object.freeze({
|
||||
},
|
||||
},
|
||||
{
|
||||
// i18n-ignore: shot title, matched by title when the recipe upgrades a saved project
|
||||
title: 'Bidur / Trishuli Consequence',
|
||||
mapStack: 'photoreal',
|
||||
lat: 27.981737,
|
||||
@@ -569,7 +597,7 @@ const BHOTE_KOSHI_NEPAL_APPEND_RECIPE = BHOTE_KOSHI_NEPAL_BASE_RECIPE
|
||||
const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
? Object.freeze({
|
||||
id: 'bhote-koshi-nepal-scene',
|
||||
title: 'Nepal Flood Incident',
|
||||
title: msg('Nepal Flood Incident'),
|
||||
durationSec: 15,
|
||||
style: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.style,
|
||||
ui: BHOTE_KOSHI_NEPAL_APPEND_RECIPE.ui,
|
||||
@@ -583,6 +611,7 @@ const BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE = BHOTE_KOSHI_NEPAL_APPEND_RECIPE
|
||||
.slice(0, 3)
|
||||
.map((keyframe, index) => ({
|
||||
...keyframe,
|
||||
// i18n-ignore: legacy shot titles the bootstrap matches verbatim
|
||||
title: `Shot ${index + 1}`,
|
||||
})),
|
||||
})
|
||||
@@ -593,7 +622,7 @@ const EVENT_RECIPES = [BHOTE_KOSHI_NEPAL_BOOTSTRAP_RECIPE].filter(Boolean);
|
||||
const PUBLIC_SCENE_RECIPES = [
|
||||
{
|
||||
id: 'flights-radar',
|
||||
title: 'Global Flights Radar',
|
||||
title: msg('Global Flights Radar'),
|
||||
durationSec: 30,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' },
|
||||
@@ -663,7 +692,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'orbital-watch',
|
||||
title: 'Orbital Watch',
|
||||
title: msg('Orbital Watch'),
|
||||
durationSec: 32,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
@@ -741,7 +770,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'thermal-threats',
|
||||
title: 'Thermal Threat Board',
|
||||
title: msg('Thermal Threat Board'),
|
||||
durationSec: 26,
|
||||
style: 'thermal',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
@@ -809,7 +838,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'city-overload',
|
||||
title: 'City Overload',
|
||||
title: msg('City Overload'),
|
||||
durationSec: 30,
|
||||
style: 'surveillance',
|
||||
ui: { hidePanels: true, hudMode: 'minimal', safeFrame: '9:16' },
|
||||
@@ -887,7 +916,7 @@ const PUBLIC_SCENE_RECIPES = [
|
||||
},
|
||||
{
|
||||
id: 'omniscience-pullback',
|
||||
title: 'Omniscience Pullback',
|
||||
title: msg('Omniscience Pullback'),
|
||||
durationSec: 36,
|
||||
style: 'retro',
|
||||
ui: { hidePanels: true, hudMode: 'full', safeFrame: '16:9' },
|
||||
@@ -982,3 +1011,55 @@ export function getSceneRecipeById(id) {
|
||||
export function getSceneAppendRecipeById(id) {
|
||||
return SCENE_APPEND_RECIPES.find((recipe) => recipe.id === id) || null;
|
||||
}
|
||||
|
||||
// Built-in shot titles stay English in recipes and saved projects (upgrades
|
||||
// and bindings match them verbatim). These are their display names; a title
|
||||
// the user typed shows as typed. Place names inside them stay Latin.
|
||||
const BUILTIN_SHOT_TITLES = new Set([
|
||||
msg('Global Incident Context'),
|
||||
msg('Nepal-Focused Globe Rotation'),
|
||||
msg('Bhote Koshi Regional Approach'),
|
||||
msg('Bhote Koshi Nearby Cities'),
|
||||
msg('Bhote Koshi Incident Corridor'),
|
||||
msg('Bhote Koshi Flood Path'),
|
||||
msg('Bhote Koshi Corridor Overview'),
|
||||
msg('Bhote Koshi Upper Valley'),
|
||||
msg('Upper Valley Collapse'),
|
||||
msg('Debris-Dammed Lake'),
|
||||
msg('Nepal-China Border Gate'),
|
||||
msg('Timure Evidence Cluster'),
|
||||
msg('Syabru Besi Passage'),
|
||||
msg('Bidur / Trishuli Consequence'),
|
||||
msg('Second landslide'),
|
||||
msg('Mailung / Upper Trishuli bridge'),
|
||||
msg('Dandagaun viewpoint restaurant'),
|
||||
msg('Devighat / Taadi Khola Bridge'),
|
||||
msg('Final view'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display title for a scene: the localized recipe name while the stored title
|
||||
* still matches the built-in verbatim, the stored title otherwise.
|
||||
* @param {{id?: string, title?: string}} scene
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displaySceneTitle(scene) {
|
||||
const builtin = SCENE_RECIPES.find((recipe) => recipe.id === scene?.id);
|
||||
if (builtin?.title && scene.title === builtin.title) return t(builtin.title);
|
||||
const title = scene?.title || '';
|
||||
const numbered = /^Scene (\d+)$/.exec(title);
|
||||
return numbered ? t('Scene {n}', { n: Number(numbered[1]) }) : title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title for a shot: built-in titles and the numbered "Shot N"
|
||||
* fallback localize; any other title shows as stored.
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displayShotTitle(title) {
|
||||
const text = typeof title === 'string' ? title : '';
|
||||
if (BUILTIN_SHOT_TITLES.has(text)) return t(text);
|
||||
const numbered = /^Shot (\d+)$/.exec(text);
|
||||
return numbered ? t('Shot {n}', { n: Number(numbered[1]) }) : text;
|
||||
}
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user