diff --git a/.env.example b/.env.example index aed99b239..c89680b30 100644 --- a/.env.example +++ b/.env.example @@ -117,20 +117,6 @@ LL2_API_TOKEN= # Optional: OpenSky credentials JSON file path (alternative to above) # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json -# Optional: interface language pair (i18n). GEV_DEFAULT_LOCALE is the default -# UI locale; GEV_SECONDARY_LOCALE is the second locale offered in the dock -# language switch. English is ALWAYS shipped as the fallback catalog and is -# resolvable no matter the pair. Only 'en' ships in this PR: both values are -# validated against the shipped catalogs, so a secondary without a shipped -# catalog degenerates the switch to a single English button (and an invalid, -# unshipped, or degenerate pair falls back to that en-only shape with a -# dev-server-only console.warn). Later locale PRs re-open the pair — see -# docs/TRANSLATORS.md. CLIENT-EXPOSED (non-secret): both values reach the -# browser bundle via import.meta.env defines — do not put anything sensitive -# here. -# GEV_DEFAULT_LOCALE=en -# GEV_SECONDARY_LOCALE=es - # Optional: dev server config PORT=4173 # Network binding. Default is localhost — the server is reachable ONLY from diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7a89a93..b104e6eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Interface localization, starting with Russian. UI text is marked with + `t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed + by the English text itself; each language is one flat JSON catalog + (`src/i18n/locales/ru.json`). The language comes from `?lang=`, the EN/RU + 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. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. @@ -318,29 +327,6 @@ keeps frame time with ALPR on within a few percent of ALPR off, seats nearby badges on the rendered surface and says when no loaded camera is on screen. -## Internationalization foundation - -- Add the locale core (`src/i18n/`): configurable locale pair - (`GEV_DEFAULT_LOCALE` / `GEV_SECONDARY_LOCALE`, validated against the - shipped catalogs), guarded preference storage, a one-shot `?lang=` - override, and ``/`` metadata. English is the only - shipped locale — the built-in pair degenerates to en-only and the dock - language switch renders a single EN button — and English behavior is - byte-identical to before. -- Extract all application-owned presentation text into four English - catalogs (`shell`/`cockpit`/`layers`/`setup`) consumed through `t()` call - sites, `data-i18n*` template attributes, and the runtime locale selector; - complete the duplicate `data-i18n` split on the first-run EXPLORE - MANUALLY tile (the subcopy was wiped in every locale). -- Enforce the parity gates every additional locale must pass (key / - placeholder / plural-shape parity, markup coverage, repair-pass anchors). - Additional locales ship as stacked follow-up PRs, one per locale, via the - locale-addition recipe in `docs/TRANSLATORS.md` (Spanish is first). - -- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths. - -- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. - - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -789,6 +775,7 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound - Separate navigation, share restoration, visual settings and panel state into focused UI owners with explicit dependencies and terminal cleanup. - Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior. + - Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths. - Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. diff --git a/build/vite.js b/build/vite.js index 745d6d384..5f1a8e964 100644 --- a/build/vite.js +++ b/build/vite.js @@ -52,8 +52,6 @@ export function createBrowserViteConfig({ publicDir, googleApiKey, cesiumToken, - defaultLocale = '', - secondaryLocale = '', mapillaryToken, host = 'localhost', port = 4173, @@ -109,14 +107,6 @@ export function createBrowserViteConfig({ define: { 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), - // Locale pair (i18n): the client's default + secondary UI locale. These - // are NOT secrets and are read by src/i18n/locale.js, which validates - // them against the shipped catalogs and falls back to the built-in - // en-only pair (while English is the only shipped catalog) for - // empty/invalid/unshipped values. Explicit define entries are required - // because Vite only auto-exposes VITE_-prefixed vars on import.meta.env. - 'import.meta.env.GEV_DEFAULT_LOCALE': JSON.stringify(defaultLocale), - 'import.meta.env.GEV_SECONDARY_LOCALE': JSON.stringify(secondaryLocale), // Mapillary client tokens are public by design (see SECURITY.md). 'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify( mapillaryToken ?? '', diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22fd95cba..aa6075682 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3009,124 +3009,28 @@ test:track` 43 tracking invariants · headless QA harnesses under > attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`. > The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history. -### Internationalization / catalog locales (September 2026) +### Interface localization (October 2026) -The application-owned UI is fully extracted into message catalogs; **en** -is the only shipped locale in this foundation state — additional locales -land as stacked follow-up PRs, one per locale (Spanish first), each -following the locale-addition recipe in -[`docs/TRANSLATORS.md`](./TRANSLATORS.md). English is the default, the -source catalog, and the fallback. The subsystem lives in `src/i18n/`: +Shipped locales: **en** (source and fallback) and **ru**. The code lives in +`src/i18n/`; the contributor workflow is in [`docs/TRANSLATORS.md`](./TRANSLATORS.md). -- **File map.** `src/i18n/locale.js` owns locale resolution, guarded storage - (`gev:locale:v1`), the one-shot `?lang=` override, and ``/`` metadata. `src/i18n/index.js` owns the catalog registry, `t()` - (interpolation + `Intl.PluralRules` plural selection), `formatNumber` / - `formatDate`, `applyDocumentTranslations()`, and - `persistLocaleAndReload()`. The shipped catalog is four flat message maps - — `src/i18n/locales/en/{shell,cockpit,layers,setup}.js`, 912 keys (shell - 34, cockpit 369, layers 388, setup 121). Plural entries are the en - `{ one, other }` shape; a future locale may add only cardinal categories - `Intl.PluralRules` reports valid for it, and an entry lacking the selected - category degrades to `other` at runtime. Locale-code normalization - (`es-MX`/`es_419` → `es`, plus fr/ru/uk and variants) ships ahead of the - locales on purpose: knowing a code is hygiene, shipping is catalog-driven - through `CATALOG_LOCALES`. -- **Selector.** The command dock carries a compact locale switch: a static - `.dock-locale-switch` group container in `index.html` whose buttons - (`.dock-locale-btn`) are rendered at runtime by `src/ui/applicationShell.js` - `_initLocaleSelector()` — one per locale in the configured pair; while - only English ships that is a single EN button. A click persists the - choice and reloads the page with the hash preserved, so no live re-apply - of already-rendered dynamic panels is needed. -- **Locale pair config.** `GEV_DEFAULT_LOCALE` and `GEV_SECONDARY_LOCALE` in - `.env`, injected as `import.meta.env` defines by `vite.config.js`; both - values are validated against the shipped catalogs (`CATALOG_LOCALES`), - and the offered set is `dedup([default, secondary, 'en'])`. While English - is the only shipped catalog the built-in pair is en-only, so every - configuration degenerates to a single offered locale; an invalid, - unshipped, or degenerate configured pair falls back to that built-in - en-only shape (dev-only warn). The first secondary-locale PR restores the - built-in en+es pair. -- **Resolution order.** `?lang=` (search string only; never - persisted, never written into share links; stripped by - `persistLocaleAndReload`; accepted only for offered locales) → stored - `gev:locale:v1` (re-checked against the pair) → - `navigator.languages` (regional variants normalize to the primary tag: - `es-MX`/`es_419` → `es`) → the CONFIGURED default locale. Unsupported - values defer to the next - step rather than forcing English. -- **Gates** (all under `node --test src/i18n/`, 40 tests): - `catalog.test.mjs` enforces per-locale key, placeholder-name, and - plural-shape parity with the strict exact-parity flip ON - (`REQUIRE_FULL_PARITY`; `GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` opts out - for staged work; the loops are vacuous while only en ships — each locale - PR meets them from day one); - `markupCoverage.test.mjs` requires every `data-i18n*` attribute in - `index.html` to resolve in every shipped catalog and rejects unknown attribute - spellings; `i18n.test.mjs` pins precedence, guards, fallback, - interpolation, the plural-selection contract (synthetic missing-category - degradation; real-catalog plural pins arrive with each locale PR), and - DOM application; `repairPass.test.mjs` anchors en byte-identity for - extracted literals and rejects a locale-scoped CCTV wrap rule shipping - ahead of its locale (per-locale value anchors arrive with each locale - PR). -- **Accepted deferrals (do not "fix" silently):** - - The military-awareness subject header literal `FLIGHT / VESSEL WINDOW` - (`src/data/militaryAwareness.js`) stays English; the literal is - test-pinned in `src/data/militaryAwareness.test.mjs`. - - The visible cockpit-brief tab tokens `SIG` / `NEWS` / `LOCAL` - (`index.html`) stay untranslated to match the runtime keys; their - `aria-label`s ARE localized (`cockpit.brief.tab*AriaLabel`). - - `src/voice/gevActions.js` tool-result confirmation strings stay English - by contract (voice tool schemas and spoken confirmations are - keep-English; see the Keep-English boundary in `docs/TRANSLATORS.md`). - - Number formatting is still the pre-i18n `toLocaleString` policy at: - `src/ui.js:1442`, `src/data/satellites.js:829`, - `src/data/flights.js:436` (`'en-US'` pinned), and - `src/data/rocketLaunches.js:2281/2299/2364/2371/2374` - (default-locale). A locale-aware number-format policy is deferred; the - `formatNumber` helper exists in `src/i18n/index.js` when that lands. - -### Overpass proxy mirror rotation (September 2026) - -- `/api/overpass` fans out across four public mirrors. `overpassPayloadIsData()` governs cache reads, writes, and stale fallback: only a 2xx that is neither rate-limited nor a body-level runtime error qualifies. Previously stored refusals are ignored on both fresh and stale reads, so upgrading does not require manually clearing the disk cache. -- HTTP refusals such as 406 now rotate alongside the existing network, rate-limit, and runtime-error cases. A refusal from one mirror no longer prevents reaching healthy alternatives or persists under the seven-day road/month-long boundary cache TTLs. Concurrent identical queries share one mirror sequence; if it fails, both the initiating and joined callers can use the same last-good data. -- A refusal every mirror agrees on is still reported with the first mirror's status and body, so a genuinely malformed query says what upstream said — but only after every mirror has had the chance to answer it. `fetchOverpassPayload` takes injectable endpoints and fetch so the rotation is tested without a live mirror (`src/overpassProxy.test.mjs`). - -### Share-link v2 layer state (August 2026) - -- Generated share links use a deterministic v2 hash. Existing camera, visual, - HUD, detection, post-processing, celestial, scope, and map-stack fields remain, - with compact fields for enabled layers, allowlisted layer options, panel state, - and the active preset's allowlisted shader controls. An absent layer field uses - deterministic defaults; an explicit empty field means no enabled layers. -- The registry seals only after all 16 production layers register, and every - layer has an explicit serialization disposition. Unknown enabled-layer tokens - reject the layer payload; unknown option tokens are ignored. Restoration - settles independently per layer so one failed or unavailable source cannot - block its siblings. -- Stable visible options are limited to aircraft 3D mode, selected civilian and - military flight IDs, Satellite catalog and selection, CCTV coverage/projection/ - auto-hop, and Radio filter/volume. Playback and tuning, live-data health, - calibration, caches, lifecycle state, temporary Context ownership, and derived - effects are deliberately excluded. Radio restore never selects or plays a - station. -- Normal loads restore the last successful explicit UI, voice, or tool choice - from versioned local storage. Any valid camera share wins for the current load - without overwriting recipient preferences. Restore ownership is split by - visibility, option/selection, camera, visual, map, and individual panel lane: - a newer explicit action supersedes only the field it owns. In particular, - navigation cannot turn unrelated layers off, and an option change cannot - -#### Keep-English adjudications (post-merge catch-ups) - -- `src/ui/visualPresets.js` `'ELASTIC'` — detection-allocation STRATEGY IDENTIFIER (persisted - under `gev:detection-allocation`, compared via `dataset.allocation ===`), not display copy. -- `src/ui/layerPanel.js` guidance meta — the `{source} · {statusMessage}` composition uses a - locale-neutral interpunct; the statusMessage itself IS localized via `installationFeedback`. -- `src/ui/mapSourceControls.js` `'...'` switching label and the orbit `'↻'` glyph — - punctuation/glyphs, identical across locales. +- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera', + '{count} cameras')`, and `msg('Live Flights')` for text stored in data and + translated later. Markup opts in with `data-i18n` (element text) and + `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English + value). English has no catalog; every other locale is + `src/i18n/locales/.json`, and a missing entry falls back to English. +- **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) → + `navigator.languages` → English. The EN/RU switch in the Visual Presets dock + stores the choice and reloads, so nothing re-renders live. +- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) + extracts every message and fails on missing, unused, placeholder-mismatched or + wrongly-shaped plural entries. +- **Not translated by design:** layer ids, share-link and hash parameters, API + fields, voice tool schemas, provider names, feed data (callsigns, vessel and + station names) and attribution. +- **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. ## Canonical Docs Order diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index bb9fdb70b..bef41b634 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -1,198 +1,94 @@ -# Translator's guide (i18n foundation — English shipped) +# Translating God's Eye View -This is the working guide for translating God's Eye View. The i18n -foundation ships **English only**: the locale core, the English -catalogs, and the parity gates are in, and every additional locale lands -as its own stacked PR, one locale at a time, following the -[locale-addition recipe](#the-locale-addition-recipe) below. That recipe -is the deliberate design artifact of this phase — a locale is not -"translated into the app", it is *shipped* through the same gate every -time. +Shipped languages: **English** (source and fallback) and **Russian** (`ru`). -## Which locale ships, and which pair is offered +## How it works -One catalog ships: **en** (source of truth and unconditional fallback). -Shipping is catalog-driven (`CATALOG_LOCALES` in `src/i18n/locale.js`): -while only English ships, the built-in locale pair is en-only and the -dock language switch renders a single EN button. Which pair the app -*offers* is configuration, not code: `GEV_DEFAULT_LOCALE` and -`GEV_SECONDARY_LOCALE` in `.env`, injected into the browser via vite -defines. Both values are validated against the shipped catalogs — an -invalid, unshipped, or degenerate pair (e.g. the same locale twice) -degenerates to the built-in en-only shape with a dev-server-only -`console.warn`. English is always resolvable — it is the fallback -catalog — even when not part of the configured pair. +The English text is the key. There is no English catalog: the code is the source. -## How the catalog system works - -All application-owned UI text lives in four flat message catalogs (en -today; one directory per locale once shipped): - -```text -src/i18n/ - locale.js pair config + resolution + storage + / - index.js catalog registry, t(), Intl formatters, DOM apply - locales/en/{shell,cockpit,layers,setup}.js +```js +t('Save keys'); // plain text +t('Saved to {store}', { store }); // named placeholders +tn(count, '{count} camera', '{count} cameras'); // count-dependent text +msg('Live Flights'); // text kept in data, translated later by t() ``` -| Namespace | Surface | -| --- | --- | -| `shell.*` | Document chrome: title, dock actions, panels, global status chip, sync chips, toasts, locale selector | -| `cockpit.*` | Cockpit HUD readouts, Context panel, CCTV scene summary, Display/panel chrome, visual presets, Radio utility | -| `layers.*` | Data-layer presentation: layer names, feed-state labels, row meta, vessel/flight/satellite/mission/CCTV/Radio/awareness cards and readouts | -| `setup.*` | First-run launcher, key setup (POWER UP), scenes/director status, map-stack chips | +```html +Normal + +``` -Each catalog file exports `NAMESPACE` and a default map of -namespace-relative keys; `mergeNamespace()` prefixes them (`cockpit.…`) -and `buildCatalog()` rejects duplicates. English is the default and the -fallback locale: a key missing in another locale renders its English -value (dev-server-only `console.warn`). The en catalogs hold 912 keys -(shell 34, cockpit 369, layers 388, setup 121). +`data-i18n` translates the element's own text (put it on the element that holds the text, not on a parent with children). `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute's English value. -## Key naming +Every other language is one flat JSON file, `src/i18n/locales/.json`, keyed by the English text: -- Markup surfaces: `..` — e.g. - `shell.panels.dataLayers`, `cockpit.readout.altitude`. -- Runtime flows/states: `..` — e.g. - `shell.status.loadingLiveData`, `layers.missions.telemetry.km`. -- Names are semantic and stable. Never rename, reorder, or renumber an - existing key: `data-i18n` attributes and runtime `t()` call sites in other - files reference them. +```json +{ + "Save keys": "Сохранить ключи", + "{count} cameras": { + "one": "{count} камера", + "few": "{count} камеры", + "many": "{count} камер", + "other": "{count} камеры" + } +} +``` -## The locale-addition recipe +A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English. -Every locale PR follows the same steps, in this order. Nothing here is -optional — the gates below fail the build if a step is skipped. +The page picks its language from `?lang=`, then the choice stored by the EN/RU switch in the Visual Presets dock, then the browser languages, then English. Switching reloads the page. -1. **Catalogs.** Copy `src/i18n/locales/en/` to - `src/i18n/locales//` and translate all four files - (`` is the primary language tag: `es`, not `es-419`). Keep the - en key order, keep key names byte-identical, and translate VALUES - only. A locale may never carry a key en lacks. -2. **Registration.** In `src/i18n/index.js`: add the four import lines - and one `_NAMESPACES` array, then one entry in the `CATALOGS` - map. Registration is append-only; nothing else in the file changes. -3. **Shipping.** Append `''` to `CATALOG_LOCALES` in - `src/i18n/locale.js`. This single constant is what makes the locale - normalizable-to-shipped: pair config, `?lang=`, storage, and the - selector all read from it. Regional variants (`es-MX`, `es_419`) - already normalize — locale-code hygiene is shipped ahead of the - locales on purpose. -4. **Selector labels.** Append - `'locale..ariaLabel': 'Switch to '` to the `shell` - catalog of EVERY shipped locale (en included — the en catalog names - the new button; the new catalog names the English one). - `markupCoverage.test.mjs` requires a label for every shipped code in - every catalog, so this cannot be forgotten. -5. **Plurals.** Plural entries are variant objects carrying a `{count}` - placeholder, selected per active locale through `Intl.PluralRules`. - English entries use `{ one, other }`. Your locale must keep every en - variant and may add ONLY categories `Intl.PluralRules` reports valid - for it (e.g. a Slavic locale adds `few`/`many`) — - `catalog.test.mjs` enforces this shape against the live ICU data. - Pin your locale's real-catalog plural agreement in - `src/i18n/i18n.test.mjs` (one/few/many/other counts where relevant). -6. **Layout.** Prefer concise glossary-approved copy over CSS surgery; - fixed-width `nowrap` panels must be checked at narrow viewports. A - locale-scoped wrap rule (`html[lang=''] .cctv-controls { - flex-wrap: wrap; }` in `src/ui/styles/cctv.css`) is added ONLY when - the locale demonstrably clips, with a comment recording the measured - widths — the English layout stays pixel-identical, and - `repairPass.test.mjs` rejects a wrap rule shipping ahead of its - locale. -7. **Gates.** `node --test src/i18n/` must be green with the strict - parity gate ON: exact key-set equality with en, identical - placeholder names, plural-shape superset. While a new catalog is a - staged untranslated seed, CI may run with - `GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0` (subset mode) — record every - such flip here, and record the flip back. -8. **Docs.** Add the locale's section + glossary to this file, note it - in `docs/CURRENT-STATE.md` (Internationalization) and `CHANGELOG.md`, - and update the shipped-locales wording in `.env.example`. - -## Append-only rules - -- New keys are appended to the END of the namespace file in EVERY - shipped locale directory in the same change. Never edit another - namespace's files, and never touch the registration arrays in - `src/i18n/index.js` without re-reading this guide first. -- A fifth namespace is a last resort (prefer fitting one of the four); its - registration recipe is the same append-only pattern as a locale (step 2 - of the recipe above), applied to every shipped locale at once. -- Parity is enforced by tests, not goodwill: no locale may carry a key en - lacks, and every shared key must keep identical placeholder names - and a plural-variant set that includes every en variant plus only - categories `Intl.PluralRules` reports for that locale - (`src/i18n/catalog.test.mjs`). - -## Placeholder, plural, and typography rules - -- Interpolation uses named `{camelCase}` placeholders: - `'Flying to {place}…'`. The **same placeholder names must exist in - every locale** — the parity gate fails on a renamed `{name}` because - it would break interpolation at runtime. -- Locale-aware numbers, dates, and relative times go through the - `formatNumber` / `formatDate` / `formatRelativeTime` helpers in - `src/i18n/index.js` — do not hand-format in a catalog call site. -- Style: the English catalog is ALL-CAPS for tactical labels, readouts, - and status text; translations follow the same convention in their - script's norms. The `·` separator used between metadata segments is - part of the surface style and is reproduced in translations. -- Instrument codes stay identical across languages; units (KTS, FT, km, - km²) are never translated. -- Romance languages run ~25% longer than English and Slavic languages - compound that on multi-word tactical labels — plan string budgets and - check fixed-width panels before review (see step 6 above). - -## Keep-English boundary (summary) - -Never catalog these — they are contracts, not copy: - -- Internal layer IDs and registry keys (`military-installations`, - `ais-live-vessels`, stack ids). -- Status enums and machine values (`FEED_STATE_LABELS` keys, lifecycle - states, detection/context modes) — translate only the final label at - the presentation boundary. -- URL/hash contracts: share-link parameters, `?welcome=`, `?lang=`, - storage schemas. -- API fields, voice tool names/schemas, `/api/setup/*` payloads. -- Provider/dataset/product names, callsigns, vessel/satellite/place - names from data, news headlines, license/source attributions. -- Console diagnostics, QA pin scripts, and existing exact-English test - expectations. -- User-authored content (annotations, labels drawn from live data). - -Adjudicated exceptions live in "Internationalization" in -[`docs/CURRENT-STATE.md`](./CURRENT-STATE.md) (accepted deferrals — do not -"fix" them silently). - -## Glossary - -Approved recurring terms per locale. Each locale PR appends its - glossary here (English term · translation · catalog evidence) so later - translators reuse approved copy verbatim. None yet — the first entry - arrives with the first translated locale. - -## Running the i18n test gates +## The check ```sh -node --test src/i18n/ # 40 tests: core + pair config, catalog parity, markup coverage, repair-pass anchors -npm test # full suite (see below for the known environmental caveat) +npm run i18n # what is missing, unused or broken, per language +npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate +npm run i18n -- --prune # drop entries the code no longer uses ``` -- `src/i18n/i18n.test.mjs` — resolution precedence, guarded storage, - fallback, interpolation, plural selection (contract + synthetic - missing-category degradation; real-catalog plural pins arrive with - each locale PR), DOM application. -- `src/i18n/catalog.test.mjs` — key + placeholder + plural-shape parity for - every shipped locale against en, strict `REQUIRE_FULL_PARITY` gate - (vacuous while only en ships; each locale PR meets it from day one). -- `src/i18n/markupCoverage.test.mjs` — every `data-i18n*` attribute in - `index.html` must resolve in every shipped catalog; unknown attribute - spellings fail loudly. -- `src/i18n/repairPass.test.mjs` — byte-identity anchors for English - literals; per-locale value anchors arrive with each locale PR. +`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. -Full-suite caveat: `src/devFreshDotenv.test.mjs`'s external-keys provenance -test fails when a provider key (e.g. `OPENAI_API_KEY`) is exported in the -running shell — an environmental false positive unrelated to i18n. +Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. + +## Fixing a translation + +Edit `src/i18n/locales/.json` and run `npm run i18n`. To see your change, open the app with `?lang=`. + +## Adding a language + +1. Create `src/i18n/locales/.json`, fill it from `npm run i18n -- --missing ` (start from `{}`), and translate every entry. +2. Import it in `src/i18n/index.js` and add it to `CATALOGS`. +3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`. +4. Add `src/i18n/locales/.json` next to `ru.json` in `scripts/package-boundaries.json`. +5. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths. + +## Rules + +- Keep every `{placeholder}` exactly as in the English; you may move it. +- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line. +- Keep the separators `·`, `—`, `→`, `↗`, `…`. +- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9). +- Units follow the language's convention (Russian: км, м/с, уз, фт). + +## Never translated + +These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content. + +## Russian glossary + +| English | Русский | +| ------------------------------- | ---------------------------------- | +| layer / data layer | слой / слой данных | +| feed | поток данных (short: поток) | +| source | источник | +| LIVE (status) / live data | ОНЛАЙН / в реальном времени | +| SIMULATED | СИМУЛЯЦИЯ | +| stale / degraded / unavailable | устарел / с перебоями / недоступен | +| key (API key) / POWER UP | ключ / ЗАПУСК | +| aircraft / flight / vessel | самолёт / рейс / судно | +| launch (rocket) / space mission | пуск / космическая миссия | +| contact / tracking | контакт / отслеживание | +| installation | объект | +| scene / shot / recipe | сцена / кадр / рецепт | +| HDG / BRG / CRS / FOV / DEST | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. | +| PREV / NEXT | ПРЕД. / СЛЕД. | diff --git a/package.json b/package.json index d9bf6cf8a..ebf0c21f1 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "doctor": "node scripts/setup-doctor.mjs", "format": "node scripts/format.mjs --write", "format:check": "node scripts/format.mjs --check", + "i18n": "node scripts/i18n.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", "dev": "vite", diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs new file mode 100644 index 000000000..b1c20bfef --- /dev/null +++ b/scripts/i18n.mjs @@ -0,0 +1,257 @@ +// Keeps the locale catalogs in step with the code. +// +// npm run i18n check every catalog (also run by npm test) +// 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 +// +// 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. +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales'); + +const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``; +const CALL = new RegExp( + String.raw`\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`, + 'g', +); + +function unquote(literal) { + const body = literal.slice(1, -1); + return body.replace( + /\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs, + (_, esc) => { + if (esc[0] === 'u' || esc[0] === 'x') { + return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16)); + } + return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc; + }, + ); +} + +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}; +function decodeHtml(text) { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => { + if (name[0] === '#') { + const hex = name[1] === 'x' || name[1] === 'X'; + return String.fromCodePoint( + parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10), + ); + } + return ENTITIES[name.toLowerCase()] ?? all; + }); +} + +const normalize = (text) => text.replace(/\s+/g, ' ').trim(); + +function trackedFiles(...pathspecs) { + return execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + ...pathspecs, + ], + { + cwd: ROOT, + encoding: 'utf8', + }, + ) + .split('\n') + .filter(Boolean); +} + +/** + * Every message the code can ask for. + * @returns {Map} + */ +export function extractMessages() { + const messages = new Map(); + const add = (text, where, plural = null) => { + if (!text) return; + const known = messages.get(text); + if (known && Boolean(known.plural) !== Boolean(plural)) { + throw new Error( + `${where}: "${text}" is used both as plural and plain text (also ${known.where})`, + ); + } + if (!known) + messages.set(text, { plural: Boolean(plural), one: plural?.one, where }); + }; + + const sources = trackedFiles( + 'src/*.js', + 'src/*.mjs', + 'src/**/*.js', + 'src/**/*.mjs', + ).filter( + (file) => + !file.startsWith('src/i18n/') && + !/\.test\.mjs$|\/testSupport\//.test(file), + ); + 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); + } else { + add(unquote(match[4]), where, { one: unquote(match[3]) }); + } + } + } + + const markup = trackedFiles('index.html', 'src/ui/templates/*.html'); + const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs; + for (const file of markup) { + const html = readFileSync(path.join(ROOT, file), 'utf8'); + for (const [, tag, attrs, text] of html.matchAll(TAG)) { + const where = `${file} <${tag}>`; + if (/\sdata-i18n(\s|=|$)/.test(attrs)) + add(normalize(decodeHtml(text)), where); + for (const [, name] of attrs.matchAll( + /\sdata-i18n-(title|aria-label|placeholder)\b/g, + )) { + const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`)); + if (value) add(decodeHtml(value[1]), where); + else + throw new Error( + `${where}: data-i18n-${name} without a ${name} attribute`, + ); + } + } + } + return messages; +} + +const placeholders = (text) => + [...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)] + .map((m) => m[1]) + .sort() + .join(','); + +export function catalogPath(locale) { + return path.join(LOCALES_DIR, `${locale}.json`); +} + +export function readCatalog(locale) { + return JSON.parse(readFileSync(catalogPath(locale), 'utf8')); +} + +export function shippedLocales() { + return trackedFiles('src/i18n/locales/*.json').map((file) => + path.basename(file, '.json'), + ); +} + +/** + * Compare one catalog with the extracted messages. + * @returns {{missing: string[], stale: string[], errors: string[]}} + */ +export function checkCatalog(locale, catalog, messages) { + const categories = new Intl.PluralRules(locale).resolvedOptions() + .pluralCategories; + const missing = []; + const errors = []; + for (const [text, info] of messages) { + if (!Object.hasOwn(catalog, text)) { + missing.push(text); + continue; + } + const entry = catalog[text]; + if (info.plural) { + if (!entry || typeof entry !== 'object') { + errors.push(`"${text}" needs plural forms ${categories.join('/')}`); + continue; + } + for (const category of categories) { + if (typeof entry[category] !== 'string') + errors.push(`"${text}" lacks the "${category}" form`); + else if (placeholders(entry[category]) !== placeholders(text)) + errors.push( + `"${text}" [${category}] placeholders differ: ${entry[category]}`, + ); + } + } else if (typeof entry !== 'string') { + errors.push(`"${text}" must be a string`); + } else if (placeholders(entry) !== placeholders(text)) { + errors.push(`"${text}" placeholders differ: ${entry}`); + } + } + const stale = Object.keys(catalog).filter((text) => !messages.has(text)); + return { missing, stale, errors }; +} + +function main(args) { + const messages = extractMessages(); + const missingFor = args.includes('--missing') + ? args[args.indexOf('--missing') + 1] + : null; + if (missingFor) { + const catalog = readCatalog(missingFor); + const out = {}; + for (const text of checkCatalog(missingFor, catalog, messages).missing) { + const info = messages.get(text); + out[text] = info.plural ? { one: info.one, other: text } : ''; + } + console.log(JSON.stringify(out, null, 2)); + return 0; + } + let failed = false; + for (const locale of shippedLocales()) { + let catalog = readCatalog(locale); + const { missing, stale, errors } = checkCatalog(locale, catalog, messages); + if (args.includes('--prune') && stale.length) { + catalog = Object.fromEntries( + Object.entries(catalog).filter(([text]) => messages.has(text)), + ); + writeFileSync( + catalogPath(locale), + `${JSON.stringify(catalog, null, 2)}\n`, + ); + console.log(`${locale}: pruned ${stale.length} unused entries`); + } else if (stale.length) { + failed = true; + console.log( + `${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`, + ); + for (const text of stale) console.log(` ${JSON.stringify(text)}`); + } + if (missing.length) { + failed = true; + console.log( + `${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`, + ); + for (const text of missing) + console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`); + } + for (const error of errors) { + failed = true; + console.log(`${locale}: ${error}`); + } + if (!missing.length && !stale.length && !errors.length) { + console.log(`${locale}: ${messages.size} messages, complete`); + } + } + return failed ? 1 : 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a7c447afd..37488c4c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -421,10 +421,7 @@ "src/localRequestGate.mjs", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -448,10 +445,7 @@ "src/data/layerSnapshot.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -465,10 +459,7 @@ "src/app/stateChannel.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -482,10 +473,7 @@ "src/ui/radioTunerModel.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -501,10 +489,7 @@ "src/ui/cctvPresentation.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -525,10 +510,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -559,10 +541,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -574,10 +553,7 @@ "src/ui/scenePresentation.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -719,10 +695,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -831,10 +804,7 @@ "src/data/cameraArrival.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -891,10 +861,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -936,10 +903,7 @@ "src/data/modelEligibility.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -995,10 +959,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1047,10 +1008,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1079,10 +1037,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium", "satellite.js"] }, @@ -1112,10 +1067,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1153,10 +1105,7 @@ "src/data/infrastructureOverlayEntry.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1186,10 +1135,7 @@ "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1233,10 +1179,7 @@ "src/layers/traffic/roadModes.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1262,10 +1205,7 @@ "src/data/cameraSensitivity.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1309,10 +1249,7 @@ "src/layers/cctv/sourcePolicy.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["hls.js", "cesium"] }, @@ -1410,10 +1347,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1433,10 +1367,7 @@ "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1488,10 +1419,7 @@ "src/maps/imageryHost.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1541,10 +1469,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -2351,10 +2276,7 @@ "src/app/embed.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [ "hls.js", @@ -2879,10 +2801,7 @@ "src/sources/infrastructureData.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [ "hls.js", @@ -2987,10 +2906,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -3021,10 +2937,7 @@ "src/ui/visualSettings.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -3047,10 +2960,7 @@ "src/ui/uiLifetime.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, @@ -3176,10 +3086,7 @@ "src/voice/pointerReticle.js", "src/i18n/index.js", "src/i18n/locale.js", - "src/i18n/locales/en/cockpit.js", - "src/i18n/locales/en/layers.js", - "src/i18n/locales/en/setup.js", - "src/i18n/locales/en/shell.js" + "src/i18n/locales/ru.json" ], "external": [] }, diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index 58a6e7c0a..a14292597 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -23,8 +23,6 @@ export default defineConfig(({ command, mode }) => { ], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, - defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '', - secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '', mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN, host: process.env.HOST, port: process.env.PORT, diff --git a/src/app/controls.js b/src/app/controls.js index 9b9bc0b98..40b354d06 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -42,10 +42,10 @@ export function createApplicationControls({ // If no share link state, do default fly-to Austin if (!styleManager.hasShareState) { - loaderStatus.textContent = t('shell.loading.status.flying'); + loaderStatus.textContent = t('Flying to Austin, TX...'); defer(flyToAustin(viewer)); } else { - loaderStatus.textContent = t('shell.loading.status.restoring'); + loaderStatus.textContent = t('Restoring shared view...'); } return { styleManager, weatherEffects, cockpitCloudEffects }; diff --git a/src/app/scene.js b/src/app/scene.js index c96099dea..62434613f 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -43,7 +43,7 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } - loaderStatus.textContent = t('shell.loading.status.configuring'); + loaderStatus.textContent = t('Configuring viewer...'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); creditContainer.id = 'cesium-credits'; @@ -62,8 +62,8 @@ export async function createApplicationScene({ configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || googleTokens || cesiumToken - ? t('shell.loading.status.tilesGoogle') - : t('shell.loading.status.tilesKeyless'); + ? t('Loading Google 3D Tiles...') + : t('Loading the keyless globe...'); const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, googleTokens, @@ -91,14 +91,17 @@ export async function createApplicationScene({ tileError, ); const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', { - detail: tileErrorDetail, - }); + loaderStatus.textContent = t( + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + { + detail: tileErrorDetail, + }, + ); } viewer.scene.globe.show = true; } - loaderStatus.textContent = t('shell.loading.status.systems'); + loaderStatus.textContent = t('Initializing systems...'); const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js index 7d7c19cdb..ad31268f5 100644 --- a/src/creditKeyboard.js +++ b/src/creditKeyboard.js @@ -21,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) { expand.setAttribute('aria-expanded', 'false'); close.setAttribute('role', 'button'); close.setAttribute('tabindex', '0'); - close.setAttribute('aria-label', t('shell.credits.closeAria')); + close.setAttribute('aria-label', t('Close data attribution')); if (expand.dataset.gevKeyboardReady === 'true') return true; expand.dataset.gevKeyboardReady = 'true'; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 23784f058..33d20f98d 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -29,7 +29,9 @@ import flightsLayer, { import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest, } from './militaryFlights.js'; -import aisLiveVesselsLayer, { _setVesselStateForTest } from './aisLiveVessels.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, +} from './aisLiveVessels.js'; import militaryInstallationsLayer from './militaryInstallations.js'; import { _resetRenderGovernorForTest, @@ -102,7 +104,9 @@ function installAwarenessRuntime({ this.innerHTML = ''; } - get hidden() { return this._hidden; } + get hidden() { + return this._hidden; + } set hidden(value) { this._hidden = value; @@ -126,9 +130,13 @@ function installAwarenessRuntime({ removeEventListener(type, listener) { listeners.get(type)?.delete(listener); }, - setInterval() { return nextTimer++; }, + setInterval() { + return nextTimer++; + }, clearInterval() {}, - requestAnimationFrame() { return nextTimer++; }, + requestAnimationFrame() { + return nextTimer++; + }, cancelAnimationFrame() {}, dispatch(type, detail) { for (const listener of listeners.get(type) || []) listener({ detail }); @@ -154,34 +162,48 @@ function installAwarenessRuntime({ // Enough surface for the real render governor to drive this viewer. requestRenderMode: false, maximumRenderTimeChange: 0, - requestRender() { renderRequests.push(Date.now()); }, + requestRender() { + renderRequests.push(Date.now()); + }, preRender: { addEventListener(listener) { preRenderListener = listener; - return () => { preRenderListener = null; }; + return () => { + preRenderListener = null; + }; }, }, }, entities: { - add(entity) { return entity; }, + add(entity) { + return entity; + }, remove() {}, }, container: new FakeElement(), }; - const layers = new Map(AWARENESS_DEPENDENCIES.map((layerId) => [layerId, { - module: { - getStats() { - const override = layerStats[layerId]; - if (override) return typeof override === 'function' ? override() : override; - return { count: 4, status: 'ready', stale: false, error: null }; + const layers = new Map( + AWARENESS_DEPENDENCIES.map((layerId) => [ + layerId, + { + module: { + getStats() { + const override = layerStats[layerId]; + if (override) + return typeof override === 'function' ? override() : override; + return { count: 4, status: 'ready', stale: false, error: null }; + }, + }, }, - }, - }])); + ]), + ); globalThis.window = fakeWindow; globalThis.document = { body: fakeBody, - getElementById() { return null; }, + getElementById() { + return null; + }, createElement() { const element = new FakeElement(); createdElements.push(element); @@ -203,7 +225,9 @@ function installAwarenessRuntime({ return { dispatch: fakeWindow.dispatch.bind(fakeWindow), - tick() { preRenderListener?.(); }, + tick() { + preRenderListener?.(); + }, viewer, renderRequests, cameraFlights, @@ -211,15 +235,18 @@ function installAwarenessRuntime({ /** Record every navigation-authority announcement, in order. */ captureAuthority() { const taken = []; - fakeWindow.addEventListener( - NAVIGATION_AUTHORITY_EVENT, - (event) => taken.push(event?.detail?.reason || 'unknown'), + fakeWindow.addEventListener(NAVIGATION_AUTHORITY_EVENT, (event) => + taken.push(event?.detail?.reason || 'unknown'), ); return taken; }, /** The standalone Context panel element renderResults() paints into. */ panel() { - return createdElements.find((element) => element.id === 'military-awareness-panel') || null; + return ( + createdElements.find( + (element) => element.id === 'military-awareness-panel', + ) || null + ); }, restore() { militaryAwarenessLayer.destroy(); @@ -241,7 +268,10 @@ function awarenessControl(dataset, { disabled = false } = {}) { return selector.includes('button') && !this.isContinuation ? this : null; }, matches(selector) { - return selector === '[data-awareness-focus-continuation]' && this.isContinuation; + return ( + selector === '[data-awareness-focus-continuation]' && + this.isContinuation + ); }, focus(options) { this.focusCalls += 1; @@ -256,10 +286,16 @@ function awarenessPanel(controls) { return { controls, continuation, - contains(element) { return controls.includes(element) || element === continuation; }, - querySelectorAll() { return this.controls; }, + contains(element) { + return controls.includes(element) || element === continuation; + }, + querySelectorAll() { + return this.controls; + }, querySelector(selector) { - return selector === '[data-awareness-focus-continuation]' ? continuation : null; + return selector === '[data-awareness-focus-continuation]' + ? continuation + : null; }, }; } @@ -268,24 +304,56 @@ test('Contacts live repaint restores only stable identity and never retargets an const previous = awarenessControl({ awarenessAction: 'previous' }); const focus = awarenessControl({ awarenessAction: 'focus' }); const next = awarenessControl({ awarenessAction: 'next' }); - const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' }); - const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' }); + const alpha = awarenessControl({ + awarenessLayer: 'flights', + awarenessId: 'abc123', + }); + const bravo = awarenessControl({ + awarenessLayer: 'military', + awarenessId: 'def456', + }); const before = awarenessPanel([previous, focus, next, alpha, bravo]); const alphaSnapshot = captureAwarenessPanelFocus(before, alpha); assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' }); assert.equal(awarenessPanelControlKey(next), 'action:next'); - const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' }); - const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]); - assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha); - assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows'); + const retainedAlpha = awarenessControl({ + awarenessLayer: 'flights', + awarenessId: 'abc123', + }); + const retained = awarenessPanel([ + previous, + focus, + next, + bravo, + retainedAlpha, + ]); + assert.equal( + restoreAwarenessPanelFocus(retained, alphaSnapshot), + retainedAlpha, + ); + assert.equal( + retainedAlpha.focusCalls, + 1, + 'identity wins even when live distance reorders rows', + ); assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true }); - const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' }); + const forward = awarenessControl({ + awarenessLayer: 'ais-live-vessels', + awarenessId: '789', + }); const departed = awarenessPanel([previous, focus, next, forward]); - assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation); - assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact'); + assert.equal( + restoreAwarenessPanelFocus(departed, alphaSnapshot), + departed.continuation, + ); + assert.equal( + forward.focusCalls, + 0, + 'automatic paging must not transfer focus to another contact', + ); assert.equal(departed.continuation.focusCalls, 1); const bravoSnapshot = captureAwarenessPanelFocus(before, bravo); @@ -294,9 +362,16 @@ test('Contacts live repaint restores only stable identity and never retargets an restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot), noRemainingRow.continuation, ); - assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list'); + assert.equal( + noRemainingRow.continuation.focusCalls, + 1, + 'the end sentinel continues Tab beyond the list', + ); - const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation); + const continuationSnapshot = captureAwarenessPanelFocus( + departed, + departed.continuation, + ); assert.deepEqual(continuationSnapshot, { key: 'continuation' }); const repaintedAgain = awarenessPanel([previous, focus, next, forward]); assert.equal( @@ -311,10 +386,12 @@ test('Contacts live repaint restores only stable identity and never retargets an }); test('Contacts continuation target is the visible explanatory note', () => { - const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url)); + const source = readLayerSource( + new URL('./militaryAwareness.js', import.meta.url), + ); assert.match( source, - /

\$\{t\('layers\.awareness\.note'\)\}<\/p>/, + /

\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/, ); assert.doesNotMatch(source, /]*data-awareness-focus-continuation/); }); @@ -322,7 +399,9 @@ test('Contacts continuation target is the visible explanatory note', () => { test('awareness disable settles every owned dependency release before resolving', async () => { let releasing = false; let releaseDependencies; - const releaseGate = new Promise((resolve) => { releaseDependencies = resolve; }); + const releaseGate = new Promise((resolve) => { + releaseDependencies = resolve; + }); const calls = []; const runtime = installAwarenessRuntime({ isEnabled: () => false, @@ -337,11 +416,20 @@ test('awareness disable settles every owned dependency release before resolving' militaryAwarenessLayer.setParams({ passive: false }); militaryAwarenessLayer.enable(); await nextTurn(); - const disabling = militaryAwarenessLayer.disable().then(() => { releasing = true; }); + const disabling = militaryAwarenessLayer.disable().then(() => { + releasing = true; + }); await nextTurn(); - assert.equal(releasing, false, 'coordinator OFF waits for owned dependency releases'); + assert.equal( + releasing, + false, + 'coordinator OFF waits for owned dependency releases', + ); assert.deepEqual( - calls.filter(({ enabled }) => !enabled).map(({ layerId }) => layerId).sort(), + calls + .filter(({ enabled }) => !enabled) + .map(({ layerId }) => layerId) + .sort(), [...AWARENESS_DEPENDENCIES].sort(), ); releaseDependencies(); @@ -368,7 +456,9 @@ test('same-turn Contacts OFF registers after every owned dependency ON', async ( militaryAwarenessLayer.setParams({ passive: false }); const disabling = militaryAwarenessLayer.disable(); assert.deepEqual( - calls.map(({ layerId, enabled }) => `${layerId}:${enabled ? 'on' : 'off'}`), + calls.map( + ({ layerId, enabled }) => `${layerId}:${enabled ? 'on' : 'off'}`, + ), [ ...AWARENESS_DEPENDENCIES.map((layerId) => `${layerId}:on`), ...AWARENESS_DEPENDENCIES.map((layerId) => `${layerId}:off`), @@ -415,15 +505,21 @@ test('operational awareness becomes ready after aircraft while deferred sources let enabled = false; let releaseAircraft; let releaseDeferred; - const aircraftGate = new Promise((resolve) => { releaseAircraft = resolve; }); - const deferredGate = new Promise((resolve) => { releaseDeferred = resolve; }); + const aircraftGate = new Promise((resolve) => { + releaseAircraft = resolve; + }); + const deferredGate = new Promise((resolve) => { + releaseDeferred = resolve; + }); const calls = []; const runtime = installAwarenessRuntime({ isEnabled: () => false, setEnabled: async (layerId, nextEnabled) => { calls.push({ layerId, enabled: nextEnabled }); if (nextEnabled) { - await (['flights', 'military'].includes(layerId) ? aircraftGate : deferredGate); + await (['flights', 'military'].includes(layerId) + ? aircraftGate + : deferredGate); } return true; }, @@ -432,19 +528,32 @@ test('operational awareness becomes ready after aircraft while deferred sources try { await militaryAwarenessLayer.disable(); militaryAwarenessLayer.setParams({ passive: false }); - const enabling = militaryAwarenessLayer.enable().then(() => { enabled = true; }); + const enabling = militaryAwarenessLayer.enable().then(() => { + enabled = true; + }); await nextTurn(); assert.equal(enabled, false, 'coordinator ON waits for aircraft readiness'); assert.deepEqual( - calls.filter(({ enabled: nextEnabled }) => nextEnabled).map(({ layerId }) => layerId).sort(), + calls + .filter(({ enabled: nextEnabled }) => nextEnabled) + .map(({ layerId }) => layerId) + .sort(), [...AWARENESS_DEPENDENCIES].sort(), ); releaseAircraft(); await enabling; - assert.equal(enabled, true, 'deferred vessels/installations do not block Contacts'); - assert.equal(calls.filter(({ layerId }) => ( - ['ais-live-vessels', 'military-installations'].includes(layerId) - )).length, 2, 'deferred requests were started in the same activation'); + assert.equal( + enabled, + true, + 'deferred vessels/installations do not block Contacts', + ); + assert.equal( + calls.filter(({ layerId }) => + ['ais-live-vessels', 'military-installations'].includes(layerId), + ).length, + 2, + 'deferred requests were started in the same activation', + ); } finally { releaseAircraft(); releaseDeferred(); @@ -459,18 +568,28 @@ test('pending mapped installations render as unknown instead of a false zero', a // Deliberately inconsistent visibility intent exercises the defensive // lifecycle guard: `enabling` must win over a stale true response. isEnabled: () => true, - getLayerLifecycleState: (layerId) => (layerId === 'military-installations' - ? { enabled: false, lifecycleState: 'enabling', uncertain: false } - : { enabled: true, lifecycleState: 'enabled', uncertain: false }), + getLayerLifecycleState: (layerId) => + layerId === 'military-installations' + ? { enabled: false, lifecycleState: 'enabling', uncertain: false } + : { enabled: true, lifecycleState: 'enabled', uncertain: false }, }); try { - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'pending-site', position)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'pending-site', position), + ); await nextTurn(); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - const installations = snapshot?.cohorts.find(({ id }) => id === 'military-installations'); - assert.equal(installations?.count, null, 'loading is not an observed empty cohort'); + const installations = snapshot?.cohorts.find( + ({ id }) => id === 'military-installations', + ); + assert.equal( + installations?.count, + null, + 'loading is not an observed empty cohort', + ); assert.match(installations?.reason || '', /unavailable/i); assert.match( runtime.panel()?.innerHTML || '', @@ -541,18 +660,44 @@ const AIS_REFRESHING_ANSWERED_STATS = aisStatsFor({ _setVesselStateForTest({ enabled: false }); test('the first-connect readout is the real one, and is exactly what the predicate keys on', () => { - assert.equal(AIS_FIRST_CONNECT_STATS.loading, true, 'the module reports itself busy while connecting'); - assert.equal(AIS_FIRST_CONNECT_STATS.lastUpdate, null, 'and has never answered'); - assert.equal(AIS_FIRST_CONNECT_STATS.count, 0, 'with a zero that means nothing yet'); + assert.equal( + AIS_FIRST_CONNECT_STATS.loading, + true, + 'the module reports itself busy while connecting', + ); + assert.equal( + AIS_FIRST_CONNECT_STATS.lastUpdate, + null, + 'and has never answered', + ); + assert.equal( + AIS_FIRST_CONNECT_STATS.count, + 0, + 'with a zero that means nothing yet', + ); assert.equal( AIS_FIRST_CONNECT_STATS.status, undefined, 'and no status at all — the status list cannot be what catches this', ); - assert.equal(AIS_SETTLED_EMPTY_STATS.loading, false, 'an answered socket is not busy'); - assert.ok(AIS_SETTLED_EMPTY_STATS.lastUpdate, 'and carries the moment it answered'); - assert.equal(AIS_REFRESHING_ANSWERED_STATS.loading, true, 'a refresh poll is busy again'); - assert.ok(AIS_REFRESHING_ANSWERED_STATS.lastUpdate, 'without erasing the answer already given'); + assert.equal( + AIS_SETTLED_EMPTY_STATS.loading, + false, + 'an answered socket is not busy', + ); + assert.ok( + AIS_SETTLED_EMPTY_STATS.lastUpdate, + 'and carries the moment it answered', + ); + assert.equal( + AIS_REFRESHING_ANSWERED_STATS.loading, + true, + 'a refresh poll is busy again', + ); + assert.ok( + AIS_REFRESHING_ANSWERED_STATS.lastUpdate, + 'without erasing the answer already given', + ); }); test('a vessel feed still connecting after the lifecycle settles reads as unknown, not an all-clear', async () => { @@ -563,16 +708,25 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow // still never delivered a position. const runtime = installAwarenessRuntime({ isEnabled: () => true, - getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled', uncertain: false }), + getLayerLifecycleState: () => ({ + enabled: true, + lifecycleState: 'enabled', + uncertain: false, + }), layerStats: { 'ais-live-vessels': AIS_FIRST_CONNECT_STATS }, }); try { - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'connecting-site', position)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'connecting-site', position), + ); await nextTurn(); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - const vessels = snapshot?.cohorts.find(({ id }) => id === 'ais-live-vessels'); + const vessels = snapshot?.cohorts.find( + ({ id }) => id === 'ais-live-vessels', + ); assert.equal( vessels?.count, null, @@ -603,17 +757,30 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn // Without this pin the fix above could degenerate into a permanent `?`. const runtime = installAwarenessRuntime({ isEnabled: () => true, - getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled', uncertain: false }), + getLayerLifecycleState: () => ({ + enabled: true, + lifecycleState: 'enabled', + uncertain: false, + }), layerStats: { 'ais-live-vessels': AIS_SETTLED_EMPTY_STATS }, }); try { - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'settled-site', position)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'settled-site', position), + ); await nextTurn(); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - const vessels = snapshot?.cohorts.find(({ id }) => id === 'ais-live-vessels'); - assert.equal(vessels?.count, 0, 'an answered empty viewport is a real observation'); + const vessels = snapshot?.cohorts.find( + ({ id }) => id === 'ais-live-vessels', + ); + assert.equal( + vessels?.count, + 0, + 'an answered empty viewport is a real observation', + ); assert.match( runtime.panel()?.innerHTML || '', /AIS vessels<\/strong>0<\/b>/, @@ -633,17 +800,30 @@ test('a still-answered feed keeps its count through later polls that set loading // refresh would be its own dishonesty, and a flickering panel besides. const runtime = installAwarenessRuntime({ isEnabled: () => true, - getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled', uncertain: false }), + getLayerLifecycleState: () => ({ + enabled: true, + lifecycleState: 'enabled', + uncertain: false, + }), layerStats: { 'ais-live-vessels': AIS_REFRESHING_ANSWERED_STATS }, }); try { - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'refreshing-site', position)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'refreshing-site', position), + ); await nextTurn(); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - const vessels = snapshot?.cohorts.find(({ id }) => id === 'ais-live-vessels'); - assert.notEqual(vessels?.count, null, 'a refresh poll does not erase an answer already given'); + const vessels = snapshot?.cohorts.find( + ({ id }) => id === 'ais-live-vessels', + ); + assert.notEqual( + vessels?.count, + null, + 'a refresh poll does not erase an answer already given', + ); } finally { restoreCollections(); runtime.restore(); @@ -658,16 +838,29 @@ test('voice reports a still-connecting vessel feed as unknown rather than zero', // zero and needs its own pin. const runtime = installAwarenessRuntime({ isEnabled: () => true, - getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled', uncertain: false }), + getLayerLifecycleState: () => ({ + enabled: true, + lifecycleState: 'enabled', + uncertain: false, + }), layerStats: { 'ais-live-vessels': AIS_FIRST_CONNECT_STATS }, }); try { - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'voice-site', position)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'voice-site', position), + ); await nextTurn(); - const spoken = contactsWindowFromSnapshot(militaryAwarenessLayer.getContextSnapshot()); - assert.equal(spoken?.vessels, 'unknown', 'voice must not speak a zero the feed never reported'); + const spoken = contactsWindowFromSnapshot( + militaryAwarenessLayer.getContextSnapshot(), + ); + assert.equal( + spoken?.vessels, + 'unknown', + 'voice must not speak a zero the feed never reported', + ); } finally { restoreCollections(); runtime.restore(); @@ -681,18 +874,45 @@ function nextTurn() { function replaceMethod(target, name, replacement, restores) { const original = target[name]; target[name] = replacement; - restores.push(() => { target[name] = original; }); + restores.push(() => { + target[name] = original; + }); } -function stubAwarenessCollections({ flights = [], military = [], vessels = [], installations = [] }) { +function stubAwarenessCollections({ + flights = [], + military = [], + vessels = [], + installations = [], +}) { const restores = []; replaceMethod(flightsLayer, 'getNearby', () => flights, restores); - replaceMethod(flightsLayer, 'getAllPositions', () => flights.map((item) => ({ ...item, id: item.icao24 })), restores); + replaceMethod( + flightsLayer, + 'getAllPositions', + () => flights.map((item) => ({ ...item, id: item.icao24 })), + restores, + ); replaceMethod(militaryFlightsLayer, 'getNearby', () => military, restores); - replaceMethod(militaryFlightsLayer, 'getAllPositions', () => military.map((item) => ({ ...item, id: item.icao24 })), restores); + replaceMethod( + militaryFlightsLayer, + 'getAllPositions', + () => military.map((item) => ({ ...item, id: item.icao24 })), + restores, + ); replaceMethod(aisLiveVesselsLayer, 'getNearby', () => vessels, restores); - replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => vessels.map((item) => ({ ...item, id: item.mmsi })), restores); - replaceMethod(militaryInstallationsLayer, 'getNearby', () => installations, restores); + replaceMethod( + aisLiveVesselsLayer, + 'getAllPositions', + () => vessels.map((item) => ({ ...item, id: item.mmsi })), + restores, + ); + replaceMethod( + militaryInstallationsLayer, + 'getNearby', + () => installations, + restores, + ); return () => restores.reverse().forEach((restore) => restore()); } @@ -701,23 +921,60 @@ function awarenessSubject(layerId, id, position) { } for (const fixture of [ - { name: 'civilian', layerId: 'flights', layer: flightsLayer, other: militaryFlightsLayer }, - { name: 'military', layerId: 'military', layer: militaryFlightsLayer, other: flightsLayer }, + { + name: 'civilian', + layerId: 'flights', + layer: flightsLayer, + other: militaryFlightsLayer, + }, + { + name: 'military', + layerId: 'military', + layer: militaryFlightsLayer, + other: flightsLayer, + }, ]) { test(`Contacts activation adopts an already tracked ${fixture.name} flight`, async () => { const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); - const subject = awarenessSubject(fixture.layerId, `${fixture.name}-tracked`, position); + const subject = awarenessSubject( + fixture.layerId, + `${fixture.name}-tracked`, + position, + ); const runtime = installAwarenessRuntime(); const restores = []; let retrackCalls = 0; try { - const trackedEntity = { gevTrackedId: `${subject.layerId}:${subject.id}` }; + const trackedEntity = { + gevTrackedId: `${subject.layerId}:${subject.id}`, + }; runtime.viewer.trackedEntity = trackedEntity; - replaceMethod(fixture.layer, 'getTrackedSubject', () => subject, restores); + replaceMethod( + fixture.layer, + 'getTrackedSubject', + () => subject, + restores, + ); replaceMethod(fixture.other, 'getTrackedSubject', () => null, restores); - replaceMethod(flightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); - replaceMethod(militaryFlightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); + replaceMethod( + flightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -727,7 +984,11 @@ for (const fixture of [ assert.equal(snapshot?.subject.id, subject.id); assert.equal(snapshot?.navigation.canPrevious, false); assert.equal(snapshot?.navigation.canNext, false); - assert.equal(retrackCalls, 0, 'adoption must not recreate the existing track'); + assert.equal( + retrackCalls, + 0, + 'adoption must not recreate the existing track', + ); assert.equal( runtime.viewer.trackedEntity, trackedEntity, @@ -773,21 +1034,42 @@ test('Contacts activation adopts the production military tracked-subject descrip position, 'the production accessor must detach its Cartesian position', ); - assert.ok(Cesium.Cartesian3.equalsEpsilon( - accessorSubject?.position, - position, - Cesium.Math.EPSILON7, - )); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + accessorSubject?.position, + position, + Cesium.Math.EPSILON7, + ), + ); accessorSubject.position.x += 100; - assert.ok(Cesium.Cartesian3.equalsEpsilon( - militaryFlightsLayer.getTrackedSubject()?.position, - position, - Cesium.Math.EPSILON7, - ), 'mutating one accessor result must not alter the tracked source position'); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + militaryFlightsLayer.getTrackedSubject()?.position, + position, + Cesium.Math.EPSILON7, + ), + 'mutating one accessor result must not alter the tracked source position', + ); runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` }; replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); - replaceMethod(flightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); - replaceMethod(militaryFlightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); + replaceMethod( + flightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -796,12 +1078,18 @@ test('Contacts activation adopts the production military tracked-subject descrip assert.equal(snapshot?.subject.layerId, 'military'); assert.equal(snapshot?.subject.id, id); assert.equal(snapshot?.subject.label, 'RCH451'); - assert.ok(Cesium.Cartesian3.equalsEpsilon( - snapshot?.subject.position, - position, - Cesium.Math.EPSILON7, - )); - assert.equal(retrackCalls, 0, 'production military adoption must preserve tracker ownership'); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + snapshot?.subject.position, + position, + Cesium.Math.EPSILON7, + ), + ); + assert.equal( + retrackCalls, + 0, + 'production military adoption must preserve tracker ownership', + ); } finally { restores.reverse().forEach((restore) => restore()); _setTrackedMilitaryRefreshStateForTest({ @@ -849,12 +1137,23 @@ test('production military tracked-subject label falls back to registration befor }); // The proximity list already labels a callsign-less contact by registration; // the tracked-subject descriptor must use the same chain instead of the hex. - assert.equal(militaryFlightsLayer.getNearby(position, 5_000)[0]?.id, 'N123AB'); + assert.equal( + militaryFlightsLayer.getNearby(position, 5_000)[0]?.id, + 'N123AB', + ); assert.equal(militaryFlightsLayer.getTrackedSubject()?.label, 'N123AB'); // getAllPositions was the last military surface still reading as raw hex. const snapshotRow = militaryFlightsLayer.getAllPositions(10)[0]; - assert.equal(snapshotRow?.label, 'N123AB', 'getAllPositions label must use the same chain'); - assert.equal(snapshotRow?.id, id, 'getAllPositions id stays the identity hex'); + assert.equal( + snapshotRow?.label, + 'N123AB', + 'getAllPositions label must use the same chain', + ); + assert.equal( + snapshotRow?.id, + id, + 'getAllPositions id stays the identity hex', + ); runtime.viewer.trackedEntity = { gevTrackedId: `military:${id}` }; replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); @@ -929,7 +1228,12 @@ test('a cached Context subject re-reads its label when enrichment lands after se }); runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` }; - replaceMethod(militaryFlightsLayer, 'getTrackedSubject', () => null, restores); + replaceMethod( + militaryFlightsLayer, + 'getTrackedSubject', + () => null, + restores, + ); replaceMethod(flightsLayer, 'trackById', () => true, restores); replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores); @@ -937,7 +1241,10 @@ test('a cached Context subject re-reads its label when enrichment lands after se await nextTurn(); // Pre-enrichment: nothing but the hex is known, so the hex is correct here. - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.label, id); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.label, + id, + ); const panel = runtime.panel(); assert.ok(panel, 'the Context panel must exist'); assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex'); @@ -962,7 +1269,11 @@ test('a cached Context subject re-reads its label when enrichment lands after se 'N123AB', 'Context must adopt the registration once enrichment supplies it', ); - assert.equal(snapshot?.subject.id, id, 'subject identity must stay the ICAO hex'); + assert.equal( + snapshot?.subject.id, + id, + 'subject identity must stay the ICAO hex', + ); // The RENDERED panel is the surface the operator actually reads. assert.ok( panel.hiddenWrites > settledWrites, @@ -973,7 +1284,11 @@ test('a cached Context subject re-reads its label when enrichment lands after se /N123AB/, 'the rendered Context panel must show the registration without a position change', ); - assert.doesNotMatch(panel.innerHTML, /ae1fa4/, 'the hex must be gone from the panel'); + assert.doesNotMatch( + panel.innerHTML, + /ae1fa4/, + 'the hex must be gone from the panel', + ); // The Cockpit signal list builds its nearby-contact titles with this exact // helper over the cohort rows, so the same fixture pins that surface too. @@ -1032,21 +1347,47 @@ test('Contacts activation adopts the production civilian tracked-subject descrip position, 'the production accessor must detach its Cartesian position', ); - assert.ok(Cesium.Cartesian3.equalsEpsilon( - accessorSubject?.position, - position, - Cesium.Math.EPSILON7, - )); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + accessorSubject?.position, + position, + Cesium.Math.EPSILON7, + ), + ); accessorSubject.position.x += 100; - assert.ok(Cesium.Cartesian3.equalsEpsilon( - flightsLayer.getTrackedSubject()?.position, - position, - Cesium.Math.EPSILON7, - ), 'mutating one accessor result must not alter the tracked source position'); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + flightsLayer.getTrackedSubject()?.position, + position, + Cesium.Math.EPSILON7, + ), + 'mutating one accessor result must not alter the tracked source position', + ); runtime.viewer.trackedEntity = { gevTrackedId: `flights:${id}` }; - replaceMethod(militaryFlightsLayer, 'getTrackedSubject', () => null, restores); - replaceMethod(flightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); - replaceMethod(militaryFlightsLayer, 'trackById', () => { retrackCalls += 1; return true; }, restores); + replaceMethod( + militaryFlightsLayer, + 'getTrackedSubject', + () => null, + restores, + ); + replaceMethod( + flightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + () => { + retrackCalls += 1; + return true; + }, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -1055,12 +1396,18 @@ test('Contacts activation adopts the production civilian tracked-subject descrip assert.equal(snapshot?.subject.layerId, 'flights'); assert.equal(snapshot?.subject.id, id); assert.equal(snapshot?.subject.label, 'DAL123'); - assert.ok(Cesium.Cartesian3.equalsEpsilon( - snapshot?.subject.position, - position, - Cesium.Math.EPSILON7, - )); - assert.equal(retrackCalls, 0, 'production civilian adoption must preserve tracker ownership'); + assert.ok( + Cesium.Cartesian3.equalsEpsilon( + snapshot?.subject.position, + position, + Cesium.Math.EPSILON7, + ), + ); + assert.equal( + retrackCalls, + 0, + 'production civilian adoption must preserve tracker ownership', + ); } finally { restores.reverse().forEach((restore) => restore()); _setTrackedFlightRefreshStateForTest({ @@ -1084,10 +1431,12 @@ test('Contacts activation adopts the production civilian tracked-subject descrip test('Contacts activation reconciles to a newer cross-layer tracked flight after dependencies settle', async () => { let releaseDependencies; - const dependencyGate = new Promise((resolve) => { releaseDependencies = resolve; }); + const dependencyGate = new Promise((resolve) => { + releaseDependencies = resolve; + }); const positions = { first: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000), - latest: Cesium.Cartesian3.fromDegrees(-97.70, 30.30, 1200), + latest: Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1200), }; let civilianTracked = awarenessSubject('flights', 'first', positions.first); let militaryTracked = null; @@ -1102,12 +1451,25 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after try { runtime.viewer.trackedEntity = { gevTrackedId: 'flights:first' }; - replaceMethod(flightsLayer, 'getTrackedSubject', () => civilianTracked, restores); - replaceMethod(militaryFlightsLayer, 'getTrackedSubject', () => militaryTracked, restores); + replaceMethod( + flightsLayer, + 'getTrackedSubject', + () => civilianTracked, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'getTrackedSubject', + () => militaryTracked, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'first'); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.id, + 'first', + ); civilianTracked = null; militaryTracked = awarenessSubject('military', 'latest', positions.latest); @@ -1116,8 +1478,14 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after await nextTurn(); await nextTurn(); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.layerId, 'military'); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'latest'); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.layerId, + 'military', + ); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.id, + 'latest', + ); } finally { releaseDependencies(); restores.reverse().forEach((restore) => restore()); @@ -1127,12 +1495,16 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after test('tracked flight cleared during Contacts activation suppresses fallback autofocus', async () => { let releaseDependencies; - const dependencyGate = new Promise((resolve) => { releaseDependencies = resolve; }); + const dependencyGate = new Promise((resolve) => { + releaseDependencies = resolve; + }); const selectedPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); - const fallbackPosition = Cesium.Cartesian3.fromDegrees(-97.70, 30.30, 1200); + const fallbackPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1200); let tracked = awarenessSubject('flights', 'selected', selectedPosition); const restoreCollections = stubAwarenessCollections({ - flights: [{ icao24: 'fallback', position: fallbackPosition, distanceM: 1000 }], + flights: [ + { icao24: 'fallback', position: fallbackPosition, distanceM: 1000 }, + ], }); const runtime = installAwarenessRuntime({ isEnabled: () => false, @@ -1147,12 +1519,25 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto try { runtime.viewer.trackedEntity = { gevTrackedId: 'flights:selected' }; replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores); - replaceMethod(militaryFlightsLayer, 'getTrackedSubject', () => null, restores); - replaceMethod(flightsLayer, 'trackById', (id) => focused.push(id) > 0, restores); + replaceMethod( + militaryFlightsLayer, + 'getTrackedSubject', + () => null, + restores, + ); + replaceMethod( + flightsLayer, + 'trackById', + (id) => focused.push(id) > 0, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'selected'); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.id, + 'selected', + ); tracked = null; runtime.viewer.trackedEntity = undefined; @@ -1181,8 +1566,16 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto test('awareness clears are scoped to the selected source layer', () => { const subject = { layerId: 'ais-live-vessels', id: '123' }; - assert.equal(awarenessClearMatchesSubject(subject, { layerId: 'military-installations' }), false); - assert.equal(awarenessClearMatchesSubject(subject, { layerId: 'ais-live-vessels' }), true); + assert.equal( + awarenessClearMatchesSubject(subject, { + layerId: 'military-installations', + }), + false, + ); + assert.equal( + awarenessClearMatchesSubject(subject, { layerId: 'ais-live-vessels' }), + true, + ); assert.equal(awarenessClearMatchesSubject(subject, null), false); }); @@ -1209,7 +1602,12 @@ test('a fast-culled subject is reported absent so the readout can hold last-know try { replaceMethod(aisLiveVesselsLayer, 'hasContact', () => present, restores); - replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => [{ id: '353136000', position }], restores); + replaceMethod( + aisLiveVesselsLayer, + 'getAllPositions', + () => [{ id: '353136000', position }], + restores, + ); replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores); replaceMethod(flightsLayer, 'getNearby', () => [], restores); replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores); @@ -1222,7 +1620,10 @@ test('a fast-culled subject is reported absent so the readout can hold last-know label: 'MAERSK DETROIT', position, }); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, true); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, + true, + ); present = false; militaryAwarenessLayer.update(); @@ -1343,7 +1744,10 @@ test('a mapped installation subject is always present — static data is never c position, }); militaryAwarenessLayer.update(); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, true); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, + true, + ); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); @@ -1351,7 +1755,11 @@ test('a mapped installation subject is always present — static data is never c }); test('layer hasContact answers presence in O(1) and declines when unloaded', () => { - for (const layer of [flightsLayer, militaryFlightsLayer, aisLiveVesselsLayer]) { + for (const layer of [ + flightsLayer, + militaryFlightsLayer, + aisLiveVesselsLayer, + ]) { assert.equal( typeof layer.hasContact, 'function', @@ -1366,14 +1774,27 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // disable() hides the collection but keeps the records, so a map lookup // alone would report a preserved subject as FRESH from hidden stale data. for (const [name, source, guard] of [ - ['flights', readLayerSource(new URL('./flights.js', import.meta.url)), - /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], - ['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)), - /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], - ['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), - /hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + [ + 'flights', + readLayerSource(new URL('./flights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/, + ], + [ + 'militaryFlights', + readLayerSource(new URL('./militaryFlights.js', import.meta.url)), + /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/, + ], + [ + 'aisLiveVessels', + readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), + /hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/, + ], ]) { - assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`); + assert.match( + source, + guard, + `${name}.hasContact must decline while the layer is disabled`, + ); } }); @@ -1385,7 +1806,12 @@ test('a disabled layer leaves the presence verdict untouched', () => { try { replaceMethod(aisLiveVesselsLayer, 'hasContact', () => verdict, restores); - replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => [{ id: '353136000', position }], restores); + replaceMethod( + aisLiveVesselsLayer, + 'getAllPositions', + () => [{ id: '353136000', position }], + restores, + ); replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores); replaceMethod(flightsLayer, 'getNearby', () => [], restores); replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores); @@ -1393,12 +1819,18 @@ test('a disabled layer leaves the presence verdict untouched', () => { militaryAwarenessLayer.setParams({ passive: false }); runtime.dispatch('gev:awareness-subject-selected', { - layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT', position, + layerId: 'ais-live-vessels', + id: '353136000', + label: 'MAERSK DETROIT', + position, }); verdict = false; militaryAwarenessLayer.update(); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, false); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subjectPresent, + false, + ); // Layer switched off: null must hold the last verdict, not revive it. verdict = null; @@ -1427,7 +1859,10 @@ function selectFlightSubject(runtime, restores, id = 'ab4991') { replaceMethod(militaryInstallationsLayer, 'getNearby', () => [], restores); militaryAwarenessLayer.setParams({ passive: false }); runtime.dispatch('gev:awareness-subject-selected', { - layerId: 'flights', id, label: 'N627CT', position, + layerId: 'flights', + id, + label: 'N627CT', + position, }); return position; } @@ -1438,17 +1873,29 @@ test('an evicted aircraft becomes CONTACT LOST instead of collapsing the panel', try { stubPresence(flightsLayer, true, restores); selectFlightSubject(runtime, restores); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'ab4991'); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.id, + 'ab4991', + ); // Exactly what flights.js emits when the poll ages a tracked plane out. stubPresence(flightsLayer, false, restores); runtime.dispatch('gev:awareness-subject-cleared', { - layerId: 'flights', id: 'ab4991', reason: 'evicted', + layerId: 'flights', + id: 'ab4991', + reason: 'evicted', }); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - assert.ok(snapshot, 'an eviction must not null the snapshot — that hides the panel'); - assert.equal(snapshot.subject.id, 'ab4991', 'the lost contact keeps its identity'); + assert.ok( + snapshot, + 'an eviction must not null the snapshot — that hides the panel', + ); + assert.equal( + snapshot.subject.id, + 'ab4991', + 'the lost contact keeps its identity', + ); assert.equal(snapshot.subjectPresent, false); assert.equal( typeof snapshot.navigation?.canNext, @@ -1467,7 +1914,12 @@ test('an evicted vessel becomes CONTACT LOST instead of collapsing the panel', ( const restores = []; try { replaceMethod(aisLiveVesselsLayer, 'hasContact', () => true, restores); - replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => [{ id: '353136000', position }], restores); + replaceMethod( + aisLiveVesselsLayer, + 'getAllPositions', + () => [{ id: '353136000', position }], + restores, + ); replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores); replaceMethod(flightsLayer, 'getNearby', () => [], restores); replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores); @@ -1475,16 +1927,23 @@ test('an evicted vessel becomes CONTACT LOST instead of collapsing the panel', ( militaryAwarenessLayer.setParams({ passive: false }); runtime.dispatch('gev:awareness-subject-selected', { - layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT', position, + layerId: 'ais-live-vessels', + id: '353136000', + label: 'MAERSK DETROIT', + position, }); // What aisLiveVessels.js emits once a selected vessel exhausts its pin. runtime.dispatch('gev:entity-selection-cleared', { - layerId: 'ais-live-vessels', reason: 'evicted', + layerId: 'ais-live-vessels', + reason: 'evicted', }); const snapshot = militaryAwarenessLayer.getContextSnapshot(); - assert.ok(snapshot, 'an eviction must not null the snapshot — that hides the panel'); + assert.ok( + snapshot, + 'an eviction must not null the snapshot — that hides the panel', + ); assert.equal(snapshot.subject.id, '353136000'); assert.equal(snapshot.subjectPresent, false); } finally { @@ -1502,7 +1961,10 @@ test('a deliberate clear still fully clears the subject', () => { assert.ok(militaryAwarenessLayer.getContextSnapshot()); // No eviction origin: click-empty-space, Escape, voice stop, layer disable. - runtime.dispatch('gev:awareness-subject-cleared', { layerId: 'flights', id: 'ab4991' }); + runtime.dispatch('gev:awareness-subject-cleared', { + layerId: 'flights', + id: 'ab4991', + }); assert.equal( militaryAwarenessLayer.getContextSnapshot(), null, @@ -1520,7 +1982,12 @@ test('a deliberate source clear still fully clears the subject', () => { const restores = []; try { replaceMethod(aisLiveVesselsLayer, 'hasContact', () => true, restores); - replaceMethod(aisLiveVesselsLayer, 'getAllPositions', () => [{ id: '353136000', position }], restores); + replaceMethod( + aisLiveVesselsLayer, + 'getAllPositions', + () => [{ id: '353136000', position }], + restores, + ); replaceMethod(aisLiveVesselsLayer, 'getNearby', () => [], restores); replaceMethod(flightsLayer, 'getNearby', () => [], restores); replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores); @@ -1528,9 +1995,14 @@ test('a deliberate source clear still fully clears the subject', () => { militaryAwarenessLayer.setParams({ passive: false }); runtime.dispatch('gev:awareness-subject-selected', { - layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT', position, + layerId: 'ais-live-vessels', + id: '353136000', + label: 'MAERSK DETROIT', + position, + }); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'ais-live-vessels', }); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'ais-live-vessels' }); assert.equal(militaryAwarenessLayer.getContextSnapshot(), null); } finally { restores.reverse().forEach((restore) => restore()); @@ -1541,11 +2013,20 @@ test('a deliberate source clear still fully clears the subject', () => { test('production eviction sites actually tag their clears', () => { // The event contract above is worthless if the real cull paths never set the // origin, so pin the three production call sites. - const flightsSource = readLayerSource(new URL('./flights.js', import.meta.url)); - const militarySource = readLayerSource(new URL('./militaryFlights.js', import.meta.url)); - const vesselsSource = readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)); + const flightsSource = readLayerSource( + new URL('./flights.js', import.meta.url), + ); + const militarySource = readLayerSource( + new URL('./militaryFlights.js', import.meta.url), + ); + const vesselsSource = readLayerSource( + new URL('./aisLiveVessels.js', import.meta.url), + ); - for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) { + for (const [name, source] of [ + ['flights', flightsSource], + ['militaryFlights', militarySource], + ]) { assert.match( source, /if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:(?:parts\.)?tracking\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/, @@ -1567,18 +2048,31 @@ test('production eviction sites actually tag their clears', () => { // The tag alone is not enough — see the behavioral test in // firmsInteraction.test.mjs. The clear must also run BEFORE renderCurrentLod, // whose registration sweep deletes the record the clear needs to see. - const firmsSource = readLayerSource(new URL('./firmsHeatmap.js', import.meta.url)); - const evictedClear = firmsSource.indexOf('clearSelectedEntityContextForLayer(id, { evicted: true });'); - const lodRebuild = firmsSource.indexOf('components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);'); - assert.ok(evictedClear > 0, 'FIRMS must mark a refresh-vanished selection as an eviction'); - assert.ok(lodRebuild > 0, 'the FIRMS refresh must settle its selection before rebuilding'); + const firmsSource = readLayerSource( + new URL('./firmsHeatmap.js', import.meta.url), + ); + const evictedClear = firmsSource.indexOf( + 'clearSelectedEntityContextForLayer(id, { evicted: true });', + ); + const lodRebuild = firmsSource.indexOf( + 'components.rendering.renderCurrentLod(true);\n if (reselected) components.selection.selectFire(reselected, false);', + ); + assert.ok( + evictedClear > 0, + 'FIRMS must mark a refresh-vanished selection as an eviction', + ); + assert.ok( + lodRebuild > 0, + 'the FIRMS refresh must settle its selection before rebuilding', + ); assert.ok( evictedClear < lodRebuild, 'the eviction clear must precede the LOD rebuild or it emits nothing at all', ); // …and the deliberate FIRMS paths (layer disable, destroy, deselect) stay untagged. assert.equal( - (firmsSource.match(/clearSelectedEntityContextForLayer\(id\);/g) || []).length, + (firmsSource.match(/clearSelectedEntityContextForLayer\(id\);/g) || []) + .length, 3, 'only the refresh-vanish site is an eviction; disable/destroy/deselect stay deliberate', ); @@ -1594,59 +2088,105 @@ test('production eviction sites actually tag their clears', () => { // =========================================================================== test('cockpit blocks only non-aircraft Context camera flights', () => { - const cockpitBody = { classList: { contains: (name) => name === 'cockpit-mode' } }; - assert.equal(contextTargetFlyToAllowed('ais-live-vessels', cockpitBody), false); - assert.equal(contextTargetFlyToAllowed('military-installations', cockpitBody), false); + const cockpitBody = { + classList: { contains: (name) => name === 'cockpit-mode' }, + }; + assert.equal( + contextTargetFlyToAllowed('ais-live-vessels', cockpitBody), + false, + ); + assert.equal( + contextTargetFlyToAllowed('military-installations', cockpitBody), + false, + ); assert.equal(contextTargetFlyToAllowed('flights', cockpitBody), true); - assert.equal(contextTargetFlyToAllowed('ais-live-vessels', { classList: { contains: () => false } }), true); + assert.equal( + contextTargetFlyToAllowed('ais-live-vessels', { + classList: { contains: () => false }, + }), + true, + ); }); test('vessel entry and selection framing both use the 3 km focus radius', () => { - assert.match(militaryAwarenessSource, /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/); - const focusRadiusUses = militaryAwarenessSource.match( - /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, - ) || []; + assert.match( + militaryAwarenessSource, + /const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/, + ); + const focusRadiusUses = + militaryAwarenessSource.match( + /new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, + ) || []; assert.equal(focusRadiusUses.length, 2); }); test('awareness rescans only for invalidation, source change, or meaningful movement', () => { - assert.equal(awarenessRefreshRequired({ - force: false, - hasResults: true, - movementM: 10, - sourceRevisionChanged: false, - }), false); - assert.equal(awarenessRefreshRequired({ - force: false, - hasResults: true, - movementM: 250, - sourceRevisionChanged: false, - }), true); - assert.equal(awarenessRefreshRequired({ - force: false, - hasResults: true, - movementM: 0, - sourceRevisionChanged: true, - }), true); + assert.equal( + awarenessRefreshRequired({ + force: false, + hasResults: true, + movementM: 10, + sourceRevisionChanged: false, + }), + false, + ); + assert.equal( + awarenessRefreshRequired({ + force: false, + hasResults: true, + movementM: 250, + sourceRevisionChanged: false, + }), + true, + ); + assert.equal( + awarenessRefreshRequired({ + force: false, + hasResults: true, + movementM: 0, + sourceRevisionChanged: true, + }), + true, + ); }); test('installation summaries disclose viewport-scoped coverage', () => { - const empty = summarizeInstallationViewport([], { available: true, stale: false }); + const empty = summarizeInstallationViewport([], { + available: true, + stale: false, + }); assert.match(empty.reason, /viewport feed/i); assert.match(empty.reason, /not a complete 250 km survey/i); - const unavailable = summarizeInstallationViewport([], { available: false, stale: false }); + const unavailable = summarizeInstallationViewport([], { + available: false, + stale: false, + }); assert.equal(unavailable.reason, 'feed unavailable'); - const retrying = summarizeInstallationViewport([], { available: false, stats: { - statusMessage: 'Overpass temporarily unavailable — retrying in 30s', - } }); - assert.equal(retrying.count, null, 'retrying is not a claim of zero mapped sites'); - assert.equal(retrying.reason, 'Overpass temporarily unavailable — retrying in 30s'); + const retrying = summarizeInstallationViewport([], { + available: false, + stats: { + statusMessage: 'Overpass temporarily unavailable — retrying in 30s', + }, + }); + assert.equal( + retrying.count, + null, + 'retrying is not a claim of zero mapped sites', + ); + assert.equal( + retrying.reason, + 'Overpass temporarily unavailable — retrying in 30s', + ); }); test('installation summaries name a subject window instead of the viewport', () => { const stats = { coverage: { kind: 'subject', radiusM: 100_000 } }; - const empty = summarizeInstallationViewport([], { available: true, stale: false, stats }); + const empty = summarizeInstallationViewport([], { + available: true, + stale: false, + stats, + }); assert.equal(empty.count, 0); assert.match(empty.reason, /none mapped within 100 km/); assert.match(empty.reason, /not a complete 250 km survey/); @@ -1659,20 +2199,28 @@ test('installation summaries name a subject window instead of the viewport', () }); test('compact Context snapshots retain installation coverage', () => { - const snapshot = buildAwarenessContextSnapshot({ - subject: { layerId: 'flights', id: 'abc' }, - evaluatedAt: 123, - radiusM: 250000, - cohorts: [{ - id: 'military-installations', - label: 'Mapped installations', - source: 'OpenStreetMap', - coverage: 'CURRENT VIEWPORT ONLY', - summary: { - relationship: 'NEARBY', count: 2, reason: 'mapped matches', nearest: [], - }, - }], - }, { canNext: true }); + const snapshot = buildAwarenessContextSnapshot( + { + subject: { layerId: 'flights', id: 'abc' }, + evaluatedAt: 123, + radiusM: 250000, + cohorts: [ + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + coverage: 'CURRENT VIEWPORT ONLY', + summary: { + relationship: 'NEARBY', + count: 2, + reason: 'mapped matches', + nearest: [], + }, + }, + ], + }, + { canNext: true }, + ); assert.equal(snapshot.cohorts[0].coverage, 'CURRENT VIEWPORT ONLY'); assert.deepEqual(snapshot.navigation, { canNext: true }); @@ -1680,7 +2228,10 @@ test('compact Context snapshots retain installation coverage', () => { test('expanded Context results omit the redundant status heading', () => { assert.doesNotMatch(militaryAwarenessSource, /military-awareness-heading/); - assert.doesNotMatch(militaryAwarenessSource, /GLOBAL CONTEXT CONTEXT ONLY<\/span>/); + assert.doesNotMatch( + militaryAwarenessSource, + /GLOBAL CONTEXT CONTEXT ONLY<\/span>/, + ); assert.match(militaryAwarenessSource, /military-awareness-subject/); assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/); assert.match(militaryAwarenessSource, /military-awareness-controls/); @@ -1691,8 +2242,12 @@ test('Context entry tracks a nearer civilian aircraft over a farther military ai const civilianPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.27, 1000); const militaryPosition = Cesium.Cartesian3.fromDegrees(-98.5, 30.27, 1000); const restoreCollections = stubAwarenessCollections({ - flights: [{ icao24: 'civilian', position: civilianPosition, distanceM: 1000 }], - military: [{ icao24: 'military', position: militaryPosition, distanceM: 70000 }], + flights: [ + { icao24: 'civilian', position: civilianPosition, distanceM: 1000 }, + ], + military: [ + { icao24: 'military', position: militaryPosition, distanceM: 70000 }, + ], }); const runtime = installAwarenessRuntime(); const restores = []; @@ -1700,8 +2255,18 @@ test('Context entry tracks a nearer civilian aircraft over a farther military ai try { runtime.viewer.camera = { positionWC: camera }; - replaceMethod(flightsLayer, 'trackById', (id) => focused.push(`flights:${id}`) > 0, restores); - replaceMethod(militaryFlightsLayer, 'trackById', (id) => focused.push(`military:${id}`) > 0, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => focused.push(`flights:${id}`) > 0, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + (id) => focused.push(`military:${id}`) > 0, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -1727,8 +2292,18 @@ test('Context entry prefers military aircraft on an exact nearest-distance tie', try { runtime.viewer.camera = { positionWC: camera }; - replaceMethod(flightsLayer, 'trackById', (id) => focused.push(`flights:${id}`) > 0, restores); - replaceMethod(militaryFlightsLayer, 'trackById', (id) => focused.push(`military:${id}`) > 0, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => focused.push(`flights:${id}`) > 0, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + (id) => focused.push(`military:${id}`) > 0, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -1752,7 +2327,12 @@ test('Context entry retries once on the next refresh after initially empty feeds try { runtime.viewer.camera = { positionWC: camera }; - replaceMethod(flightsLayer, 'trackById', (id) => focused.push(id) > 0, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => focused.push(id) > 0, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -1772,7 +2352,11 @@ test('user deselect cancels a pending Context entry auto-focus retry', async () const flights = []; const camera = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); const selectedPosition = Cesium.Cartesian3.fromDegrees(-97.72, 30.27, 1000); - const replacementPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.27, 1000); + const replacementPosition = Cesium.Cartesian3.fromDegrees( + -97.73, + 30.27, + 1000, + ); const restoreCollections = stubAwarenessCollections({ flights }); const runtime = installAwarenessRuntime(); const restores = []; @@ -1780,7 +2364,12 @@ test('user deselect cancels a pending Context entry auto-focus retry', async () try { runtime.viewer.camera = { positionWC: camera }; - replaceMethod(flightsLayer, 'trackById', (id) => focused.push(id) > 0, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => focused.push(id) > 0, + restores, + ); militaryAwarenessLayer.setParams({ passive: false }); await nextTurn(); @@ -1791,7 +2380,11 @@ test('user deselect cancels a pending Context entry auto-focus retry', async () awarenessSubject('flights', 'user-selected', selectedPosition), ); runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - flights.push({ icao24: 'replacement', position: replacementPosition, distanceM: 1000 }); + flights.push({ + icao24: 'replacement', + position: replacementPosition, + distanceM: 1000, + }); runtime.tick(); assert.deepEqual(focused, []); @@ -1815,31 +2408,55 @@ test('reset camera release preserves the selected Contact for explicit refocus', 'gev:awareness-subject-selected', awarenessSubject('flights', 'selected-flight', selectedPosition), ); - replaceMethod(flightsLayer, 'stopTracking', (options) => { - released.push('flights'); - releaseOptions.push(['flights', options]); - runtime.dispatch('gev:awareness-subject-cleared', { - layerId: 'flights', - id: 'selected-flight', - }); - return true; - }, restores); - replaceMethod(militaryFlightsLayer, 'stopTracking', (options) => { - released.push('military'); - releaseOptions.push(['military', options]); - return true; - }, restores); - replaceMethod(aisLiveVesselsLayer, 'clearSelection', () => released.push('ais') > 0, restores); + replaceMethod( + flightsLayer, + 'stopTracking', + (options) => { + released.push('flights'); + releaseOptions.push(['flights', options]); + runtime.dispatch('gev:awareness-subject-cleared', { + layerId: 'flights', + id: 'selected-flight', + }); + return true; + }, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'stopTracking', + (options) => { + released.push('military'); + releaseOptions.push(['military', options]); + return true; + }, + restores, + ); + replaceMethod( + aisLiveVesselsLayer, + 'clearSelection', + () => released.push('ais') > 0, + restores, + ); - assert.equal(militaryAwarenessLayer.releaseCameraOwnership({ origin: 'tool' }), true); + assert.equal( + militaryAwarenessLayer.releaseCameraOwnership({ origin: 'tool' }), + true, + ); assert.deepEqual(released, ['flights', 'military', 'ais']); assert.deepEqual(releaseOptions, [ ['flights', { origin: 'tool' }], ['military', { origin: 'tool' }], ]); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'selected-flight'); - assert.equal(_getAwarenessNavigationStateForTest().pendingSelectionKey, null); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.subject.id, + 'selected-flight', + ); + assert.equal( + _getAwarenessNavigationStateForTest().pendingSelectionKey, + null, + ); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); @@ -1862,11 +2479,21 @@ test('same-layer selection clear is suppressed during a synchronous navigation r const restores = []; try { - replaceMethod(flightsLayer, 'trackById', () => { - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - return true; - }, restores); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); + replaceMethod( + flightsLayer, + 'trackById', + () => { + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + return true; + }, + restores, + ); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(militaryAwarenessLayer.getContextSnapshot()?.subject.id, 'a'); @@ -1887,12 +2514,14 @@ test('same-layer selection clear is suppressed during a synchronous navigation r test('FOCUS on an already-tracked subject clears its suppression keys before a later deselect', () => { const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); - const flights = [{ - icao24: 'a', - callsign: 'A', - position, - distanceM: 0, - }]; + const flights = [ + { + icao24: 'a', + callsign: 'A', + position, + distanceM: 0, + }, + ]; const restoreCollections = stubAwarenessCollections({ flights }); const runtime = installAwarenessRuntime(); const restores = []; @@ -1901,21 +2530,41 @@ test('FOCUS on an already-tracked subject clears its suppression keys before a l let refocusCalls = 0; let trackCalls = 0; let refocusOptions = null; - replaceMethod(flightsLayer, 'refocusTrackedById', (_id, options) => { - refocusCalls += 1; - refocusOptions = options; - return true; - }, restores); - replaceMethod(flightsLayer, 'trackById', () => { - trackCalls += 1; - return true; - }, restores); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', position)); + replaceMethod( + flightsLayer, + 'refocusTrackedById', + (_id, options) => { + refocusCalls += 1; + refocusOptions = options; + return true; + }, + restores, + ); + replaceMethod( + flightsLayer, + 'trackById', + () => { + trackCalls += 1; + return true; + }, + restores, + ); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', position), + ); - assert.equal(militaryAwarenessLayer.focusCurrent({ origin: 'voice' }), true); + assert.equal( + militaryAwarenessLayer.focusCurrent({ origin: 'voice' }), + true, + ); assert.equal(refocusCalls, 1); assert.deepEqual(refocusOptions, { origin: 'voice' }); - assert.equal(trackCalls, 0, 'an owned Contact follow should reframe without recreating selection'); + assert.equal( + trackCalls, + 0, + 'an owned Contact follow should reframe without recreating selection', + ); assert.deepEqual(_getAwarenessNavigationStateForTest(), { historyKeys: ['flights:a'], navigationVisitedKeys: ['flights:a'], @@ -1935,10 +2584,12 @@ test('FOCUS on an already-tracked subject clears its suppression keys before a l }); test('NEXT wraps through a fresh visited cycle instead of ping-ponging after exhaustion', () => { - const positions = Object.fromEntries(['a', 'b', 'c', 'd'].map((id, index) => [ - id, - Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), - ])); + const positions = Object.fromEntries( + ['a', 'b', 'c', 'd'].map((id, index) => [ + id, + Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), + ]), + ); const flights = Object.entries(positions).map(([id, position], index) => ({ icao24: id, callsign: id.toUpperCase(), @@ -1951,16 +2602,26 @@ test('NEXT wraps through a fresh visited cycle instead of ping-ponging after exh const focused = []; try { - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(id); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, positions[id]), - ); - return true; - }, restores); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(id); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', id, positions[id]), + ); + return true; + }, + restores, + ); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.deepEqual(_getAwarenessNavigationStateForTest(), { @@ -1999,10 +2660,12 @@ test('NEXT wraps through a fresh visited cycle instead of ping-ponging after exh test('NEXT reaches beyond the panel cap when more nearby targets exist', () => { const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l']; - const positions = Object.fromEntries(ids.map((id, index) => [ - id, - Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.001, 30.27, 1000), - ])); + const positions = Object.fromEntries( + ids.map((id, index) => [ + id, + Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.001, 30.27, 1000), + ]), + ); const flights = Object.entries(positions).map(([id, position], index) => ({ icao24: id, callsign: id.toUpperCase(), @@ -2015,17 +2678,27 @@ test('NEXT reaches beyond the panel cap when more nearby targets exist', () => { const focused = []; try { - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(id); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, positions[id]), - ); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(id); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', id, positions[id]), + ); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); for (let index = 0; index < 11; index += 1) { assert.equal(militaryAwarenessLayer.navigateNext(), true); @@ -2061,31 +2734,54 @@ test('NEXT jumps to expanded flight search after fully cycling nearby flight can distanceM: AWARENESS_RADIUS_M + 1000, }; - const restoreCollections = stubAwarenessCollections({ flights: nearbyFlights }); + const restoreCollections = stubAwarenessCollections({ + flights: nearbyFlights, + }); const runtime = installAwarenessRuntime(); const restores = []; const focused = []; try { let getNearbyCalls = 0; - replaceMethod(flightsLayer, 'getNearby', (position, radius) => { - getNearbyCalls += 1; - if (radius <= AWARENESS_RADIUS_M) { - return nearbyFlights; - } - return [...nearbyFlights, farFlight]; - }, restores); - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(id); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, id === 'a' ? subjectPosition : { b: nearby.b, c: nearby.c, d: expanded.d }[id]), - ); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'getNearby', + (position, radius) => { + getNearbyCalls += 1; + if (radius <= AWARENESS_RADIUS_M) { + return nearbyFlights; + } + return [...nearbyFlights, farFlight]; + }, + restores, + ); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(id); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject( + 'flights', + id, + id === 'a' + ? subjectPosition + : { b: nearby.b, c: nearby.c, d: expanded.d }[id], + ), + ); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', subjectPosition)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', subjectPosition), + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(militaryAwarenessLayer.navigateNext(), true); @@ -2111,24 +2807,42 @@ test('NEXT can restrict navigation to a requested layer', () => { const focused = []; try { - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(`flights:${id}`); - return true; - }, restores); - replaceMethod(militaryFlightsLayer, 'trackById', (id, options) => { - focused.push({ target: `military:${id}`, options }); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(`flights:${id}`); + return true; + }, + restores, + ); + replaceMethod( + militaryFlightsLayer, + 'trackById', + (id, options) => { + focused.push({ target: `military:${id}`, options }); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO)); - assert.equal(militaryAwarenessLayer.navigateNext({ - targetLayer: 'military', - origin: 'voice', - }), true); - assert.deepEqual(focused, [{ - target: 'military:m1', - options: { origin: 'voice' }, - }]); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO), + ); + assert.equal( + militaryAwarenessLayer.navigateNext({ + targetLayer: 'military', + origin: 'voice', + }), + true, + ); + assert.deepEqual(focused, [ + { + target: 'military:m1', + options: { origin: 'voice' }, + }, + ]); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); @@ -2138,9 +2852,24 @@ test('NEXT can restrict navigation to a requested layer', () => { test('NEXT can restrict navigation to an aircraftClass', () => { const flights = [ - { icao24: 'f1', callsign: 'F1', distanceM: 1000, aircraftClass: 'airliner' }, - { icao24: 'f2', callsign: 'F2', distanceM: 1500, aircraftClass: 'helicopter' }, - { icao24: 'f3', callsign: 'F3', distanceM: 2000, aircraftClass: 'airliner' }, + { + icao24: 'f1', + callsign: 'F1', + distanceM: 1000, + aircraftClass: 'airliner', + }, + { + icao24: 'f2', + callsign: 'F2', + distanceM: 1500, + aircraftClass: 'helicopter', + }, + { + icao24: 'f3', + callsign: 'F3', + distanceM: 2000, + aircraftClass: 'airliner', + }, ]; const restoreCollections = stubAwarenessCollections({ flights }); const runtime = installAwarenessRuntime(); @@ -2148,13 +2877,24 @@ test('NEXT can restrict navigation to an aircraftClass', () => { const focused = []; try { - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(id); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(id); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO)); - assert.equal(militaryAwarenessLayer.navigateNext({ aircraftClass: 'helicopter' }), true); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'f1', Cesium.Cartesian3.ZERO), + ); + assert.equal( + militaryAwarenessLayer.navigateNext({ aircraftClass: 'helicopter' }), + true, + ); assert.deepEqual(focused, ['f2']); } finally { restores.reverse().forEach((restore) => restore()); @@ -2167,32 +2907,56 @@ test('Cockpit NEXT ignores a nearer vessel and selects the next aircraft', () => const subjectPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); const nextPosition = Cesium.Cartesian3.fromDegrees(-97.73, 30.28, 1200); const vesselPosition = Cesium.Cartesian3.fromDegrees(-97.739, 30.271, 0); - const flights = [{ - icao24: 'f2', callsign: 'F2', distanceM: 1500, position: nextPosition, - }]; - const vessels = [{ - mmsi: 'v1', id: 'v1', name: 'VESSEL', distanceM: 100, position: vesselPosition, - }]; + const flights = [ + { + icao24: 'f2', + callsign: 'F2', + distanceM: 1500, + position: nextPosition, + }, + ]; + const vessels = [ + { + mmsi: 'v1', + id: 'v1', + name: 'VESSEL', + distanceM: 100, + position: vesselPosition, + }, + ]; const restoreCollections = stubAwarenessCollections({ flights, vessels }); const runtime = installAwarenessRuntime(); const restores = []; const focused = []; try { - replaceMethod(flightsLayer, 'trackById', (id) => { - focused.push(`flights:${id}`); - return true; - }, restores); - replaceMethod(aisLiveVesselsLayer, 'selectById', (id) => { - focused.push(`vessel:${id}`); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + focused.push(`flights:${id}`); + return true; + }, + restores, + ); + replaceMethod( + aisLiveVesselsLayer, + 'selectById', + (id) => { + focused.push(`vessel:${id}`); + return true; + }, + restores, + ); runtime.dispatch( 'gev:awareness-subject-selected', awarenessSubject('flights', 'f1', subjectPosition), ); - assert.equal(militaryAwarenessLayer.navigateNext({ aircraftOnly: true }), true); + assert.equal( + militaryAwarenessLayer.navigateNext({ aircraftOnly: true }), + true, + ); assert.deepEqual(focused, ['flights:f2']); } finally { restores.reverse().forEach((restore) => restore()); @@ -2207,15 +2971,27 @@ test('history navigation applies layer and aircraft-class filters in both direct { layerId: 'military', id: 'fighter', aircraftClass: 'fighter' }, { layerId: 'military', id: 'helo', aircraftClass: 'helicopter' }, ]; - assert.equal(findCompatibleHistoryIndex(history, 0, 1, { - targetLayer: 'military', aircraftClass: 'helicopter', - }), 2); - assert.equal(findCompatibleHistoryIndex(history, 2, -1, { - targetLayer: 'flights', aircraftClass: 'airliner', - }), 0); - assert.equal(findCompatibleHistoryIndex(history, 2, -1, { - targetLayer: 'military', aircraftClass: 'helicopter', - }), -1); + assert.equal( + findCompatibleHistoryIndex(history, 0, 1, { + targetLayer: 'military', + aircraftClass: 'helicopter', + }), + 2, + ); + assert.equal( + findCompatibleHistoryIndex(history, 2, -1, { + targetLayer: 'flights', + aircraftClass: 'airliner', + }), + 0, + ); + assert.equal( + findCompatibleHistoryIndex(history, 2, -1, { + targetLayer: 'military', + aircraftClass: 'helicopter', + }), + -1, + ); }); test('Cockpit history navigation skips vessels and installations in both directions', () => { @@ -2225,12 +3001,23 @@ test('Cockpit history navigation skips vessels and installations in both directi { layerId: 'military-installations', id: 'base' }, { layerId: 'military', id: 'fighter' }, ]; - assert.equal(findCompatibleHistoryIndex(history, 0, 1, { aircraftOnly: true }), 3); - assert.equal(findCompatibleHistoryIndex(history, 3, -1, { aircraftOnly: true }), 0); + assert.equal( + findCompatibleHistoryIndex(history, 0, 1, { aircraftOnly: true }), + 3, + ); + assert.equal( + findCompatibleHistoryIndex(history, 3, -1, { aircraftOnly: true }), + 0, + ); }); test('production-shaped history snapshots retain aircraft class from the selected source record', () => { - const subject = { layerId: 'military', id: 'helo-1', label: 'HELO1', position: {} }; + const subject = { + layerId: 'military', + id: 'helo-1', + label: 'HELO1', + position: {}, + }; const snapshot = historySubjectSnapshot(subject, { icao24: 'helo-1', aircraftClass: 'helicopter', @@ -2240,10 +3027,12 @@ test('production-shaped history snapshots retain aircraft class from the selecte }); test('NEXT steps over a history contact whose layer has since evicted it', () => { - const positions = Object.fromEntries(['a', 'b', 'c'].map((id, index) => [ - id, - Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), - ])); + const positions = Object.fromEntries( + ['a', 'b', 'c'].map((id, index) => [ + id, + Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), + ]), + ); const flights = Object.entries(positions).map(([id, position], index) => ({ icao24: id, callsign: id.toUpperCase(), @@ -2258,28 +3047,39 @@ test('NEXT steps over a history contact whose layer has since evicted it', () => try { replaceMethod(flightsLayer, 'refocusTrackedById', () => false, restores); - replaceMethod(flightsLayer, 'trackById', (id) => { - // An evicted contact is gone from the layer: it cannot be tracked, and - // it announces no selection. - if (evicted.has(id)) return false; - focused.push(id); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, positions[id]), - ); - return true; - }, restores); - - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); - assert.equal(militaryAwarenessLayer.navigateNext(), true); - assert.equal(militaryAwarenessLayer.navigateNext(), true); - assert.equal(militaryAwarenessLayer.navigatePrevious(), true); - assert.equal(militaryAwarenessLayer.navigatePrevious(), true); - assert.deepEqual( - _getAwarenessNavigationStateForTest().historyKeys, - ['flights:a', 'flights:b', 'flights:c'], + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + // An evicted contact is gone from the layer: it cannot be tracked, and + // it announces no selection. + if (evicted.has(id)) return false; + focused.push(id); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', id, positions[id]), + ); + return true; + }, + restores, ); + + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); + assert.equal(militaryAwarenessLayer.navigateNext(), true); + assert.equal(militaryAwarenessLayer.navigateNext(), true); + assert.equal(militaryAwarenessLayer.navigatePrevious(), true); + assert.equal(militaryAwarenessLayer.navigatePrevious(), true); + assert.deepEqual(_getAwarenessNavigationStateForTest().historyKeys, [ + 'flights:a', + 'flights:b', + 'flights:c', + ]); assert.equal(_getAwarenessNavigationStateForTest().navigationIndex, 0); // CONTACT LOST: the cull evicts b while it still sits in navigation history. @@ -2299,10 +3099,12 @@ test('NEXT steps over a history contact whose layer has since evicted it', () => }); test('a fully evicted forward history falls through to the live cohort', () => { - const positions = Object.fromEntries(['a', 'b', 'c'].map((id, index) => [ - id, - Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), - ])); + const positions = Object.fromEntries( + ['a', 'b', 'c'].map((id, index) => [ + id, + Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), + ]), + ); const flights = Object.entries(positions).map(([id, position], index) => ({ icao24: id, callsign: id.toUpperCase(), @@ -2317,18 +3119,28 @@ test('a fully evicted forward history falls through to the live cohort', () => { try { replaceMethod(flightsLayer, 'refocusTrackedById', () => false, restores); - replaceMethod(flightsLayer, 'trackById', (id) => { - if (evicted.has(id)) return false; - focused.push(id); - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, positions[id]), - ); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + if (evicted.has(id)) return false; + focused.push(id); + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', id, positions[id]), + ); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(militaryAwarenessLayer.navigatePrevious(), true); assert.equal(_getAwarenessNavigationStateForTest().navigationIndex, 0); @@ -2354,9 +3166,16 @@ test('every Context camera flight without a tracked entity takes navigation auth // setting one, so an earlier deferred geocode would still match the // generation it captured and could resolve on top of the new Context focus. const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000); - const vessels = [{ mmsi: '111', id: '111', name: 'VESSEL', position, distanceM: 900 }]; - const installations = [{ id: 'inst-1', name: 'BASE', position, distanceM: 1200 }]; - const restoreCollections = stubAwarenessCollections({ vessels, installations }); + const vessels = [ + { mmsi: '111', id: '111', name: 'VESSEL', position, distanceM: 900 }, + ]; + const installations = [ + { id: 'inst-1', name: 'BASE', position, distanceM: 1200 }, + ]; + const restoreCollections = stubAwarenessCollections({ + vessels, + installations, + }); const runtime = installAwarenessRuntime(); const restores = []; const taken = runtime.captureAuthority(); @@ -2369,7 +3188,12 @@ test('every Context camera flight without a tracked entity takes navigation auth () => [{ id: '111', position }], restores, ); - replaceMethod(militaryInstallationsLayer, 'focusById', () => true, restores); + replaceMethod( + militaryInstallationsLayer, + 'focusById', + () => true, + restores, + ); runtime.dispatch( 'gev:awareness-subject-selected', @@ -2382,7 +3206,10 @@ test('every Context camera flight without a tracked entity takes navigation auth assert.equal(runtime.cameraFlights.length, 1); militaryAwarenessLayer.focusTarget('military-installations', 'inst-1'); - assert.deepEqual(taken, ['context-vessel-focus', 'context-installation-focus']); + assert.deepEqual(taken, [ + 'context-vessel-focus', + 'context-installation-focus', + ]); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); @@ -2396,9 +3223,8 @@ test('Contacts vessel autofocus takes navigation authority before it frames', as const runtime = installAwarenessRuntime(); const restores = []; const taken = []; - runtime.window.addEventListener( - NAVIGATION_AUTHORITY_EVENT, - (event) => taken.push(event?.detail), + runtime.window.addEventListener(NAVIGATION_AUTHORITY_EVENT, (event) => + taken.push(event?.detail), ); try { @@ -2416,10 +3242,12 @@ test('Contacts vessel autofocus takes navigation authority before it frames', as // Activation enables dependencies before it auto-focuses. await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel'); - assert.deepEqual(taken, [{ - reason: 'context-vessel-autofocus', - cancelPendingSelection: false, - }]); + assert.deepEqual(taken, [ + { + reason: 'context-vessel-autofocus', + cancelPendingSelection: false, + }, + ]); } finally { restores.reverse().forEach((restore) => restore()); runtime.restore(); @@ -2432,10 +3260,27 @@ test('NEXT reaches every layer before repeating any contact', () => { // an already-visited same-layer contact outranked every unvisited contact in // the other three layers and NEXT ping-ponged inside one layer forever. const cohorts = [ - { id: 'flights', summary: { nearest: [{ icao24: 'f1', distanceM: 100 }, { icao24: 'f2', distanceM: 200 }] } }, - { id: 'military', summary: { nearest: [{ icao24: 'm1', distanceM: 300 }] } }, - { id: 'ais-live-vessels', summary: { nearest: [{ mmsi: 'v1', distanceM: 400 }] } }, - { id: 'military-installations', summary: { nearest: [{ id: 'i1', distanceM: 500 }] } }, + { + id: 'flights', + summary: { + nearest: [ + { icao24: 'f1', distanceM: 100 }, + { icao24: 'f2', distanceM: 200 }, + ], + }, + }, + { + id: 'military', + summary: { nearest: [{ icao24: 'm1', distanceM: 300 }] }, + }, + { + id: 'ais-live-vessels', + summary: { nearest: [{ mmsi: 'v1', distanceM: 400 }] }, + }, + { + id: 'military-installations', + summary: { nearest: [{ id: 'i1', distanceM: 500 }] }, + }, ]; const subject = { layerId: 'flights', id: 'f0' }; const visited = new Set(['flights:f0']); @@ -2444,7 +3289,9 @@ test('NEXT reaches every layer before repeating any contact', () => { // Walk the whole cohort. Under the old comparator step 3 handed back the // already-visited f1, because subject-layer affinity outranked visited state. for (let step = 0; step < 5; step += 1) { - const [next] = getAwarenessNavigationTargets(cohorts, subject, [...visited]); + const [next] = getAwarenessNavigationTargets(cohorts, subject, [ + ...visited, + ]); assert.ok(next, `step ${step} produced no target`); const key = `${next.layerId}:${next.id}`; assert.equal(visited.has(key), false, `step ${step} repeated ${key}`); @@ -2466,10 +3313,16 @@ test('the navigable cohort is generous but finite, and the count stays exact', ( id: `installation-${index}`, distanceM: index, })); - const summary = summarizeInstallationViewport(items, { available: true, stale: false }); + const summary = summarizeInstallationViewport(items, { + available: true, + stale: false, + }); assert.equal(summary.count, 12000, 'the count reflects every in-range item'); assert.equal(summary.nearest.length, 10, 'the panel still shows ten'); - assert.ok(Number.isFinite(AWARENESS_QUERY_LIMIT), 'the fetch limit is finite'); + assert.ok( + Number.isFinite(AWARENESS_QUERY_LIMIT), + 'the fetch limit is finite', + ); assert.ok( summary.navigationNearest.length < 12000, 'the navigable window is bounded', @@ -2484,10 +3337,12 @@ test('walking a cohort does not rescan the source layer per selection', () => { // Each selection used to pay a fresh full-layer proximity scan just to recover // the aircraft class for its history snapshot. The sweep already holds that // record, so a NEXT burst must not grow the scan count. - const positions = Object.fromEntries(['a', 'b', 'c', 'd'].map((id, index) => [ - id, - Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), - ])); + const positions = Object.fromEntries( + ['a', 'b', 'c', 'd'].map((id, index) => [ + id, + Cesium.Cartesian3.fromDegrees(-97.74 + index * 0.01, 30.27, 1000), + ]), + ); const flights = Object.entries(positions).map(([id, position], index) => ({ icao24: id, callsign: id.toUpperCase(), @@ -2502,23 +3357,38 @@ test('walking a cohort does not rescan the source layer per selection', () => { try { const scanning = flightsLayer.getNearby; - replaceMethod(flightsLayer, 'getNearby', (...args) => { - // The history-snapshot lookup has a signature all its own: a tight 1 km - // radius with a 25-row cap. Cohort sweeps use the awareness radius. - const [, range, maxCount] = args; - if (range === 1000 && maxCount === 25) historyScans += 1; - return scanning.apply(flightsLayer, args); - }, restores); - replaceMethod(flightsLayer, 'trackById', (id) => { - runtime.dispatch('gev:entity-selection-cleared', { layerId: 'flights' }); - runtime.dispatch( - 'gev:awareness-subject-selected', - awarenessSubject('flights', id, positions[id]), - ); - return true; - }, restores); + replaceMethod( + flightsLayer, + 'getNearby', + (...args) => { + // The history-snapshot lookup has a signature all its own: a tight 1 km + // radius with a 25-row cap. Cohort sweeps use the awareness radius. + const [, range, maxCount] = args; + if (range === 1000 && maxCount === 25) historyScans += 1; + return scanning.apply(flightsLayer, args); + }, + restores, + ); + replaceMethod( + flightsLayer, + 'trackById', + (id) => { + runtime.dispatch('gev:entity-selection-cleared', { + layerId: 'flights', + }); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', id, positions[id]), + ); + return true; + }, + restores, + ); - runtime.dispatch('gev:awareness-subject-selected', awarenessSubject('flights', 'a', positions.a)); + runtime.dispatch( + 'gev:awareness-subject-selected', + awarenessSubject('flights', 'a', positions.a), + ); historyScans = 0; assert.equal(militaryAwarenessLayer.navigateNext(), true); @@ -2546,13 +3416,48 @@ test('the contacts window reports exactly the counts the panel renders', () => { // honest. The window block exists so the panel's numbers travel with the // answer, so it must be derived from the panel's own snapshot, not recomputed. const results = { - subject: { layerId: 'flights', id: 'a52e54', label: 'ASA635', position: Cesium.Cartesian3.ZERO }, + subject: { + layerId: 'flights', + id: 'a52e54', + label: 'ASA635', + position: Cesium.Cartesian3.ZERO, + }, evaluatedAt: Date.now(), radiusM: AWARENESS_RADIUS_M, cohorts: [ - { id: 'flights', label: 'Flights', source: 'OpenSky', summary: { relationship: 'nearby', count: 42, reason: 'observed', nearest: [] } }, - { id: 'military', label: 'Military flights', source: 'adsb.lol', summary: { relationship: 'nearby', count: 13, reason: 'observed', nearest: [] } }, - { id: 'ais-live-vessels', label: 'AIS vessels', source: 'AISStream', summary: { relationship: 'unknown', count: null, reason: 'feed unavailable', nearest: [] } }, + { + id: 'flights', + label: 'Flights', + source: 'OpenSky', + summary: { + relationship: 'nearby', + count: 42, + reason: 'observed', + nearest: [], + }, + }, + { + id: 'military', + label: 'Military flights', + source: 'adsb.lol', + summary: { + relationship: 'nearby', + count: 13, + reason: 'observed', + nearest: [], + }, + }, + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'AISStream', + summary: { + relationship: 'unknown', + count: null, + reason: 'feed unavailable', + nearest: [], + }, + }, ], }; const snapshot = buildAwarenessContextSnapshot(results, {}); @@ -2636,7 +3541,10 @@ test('installation summaries keep the full navigation cohort beyond the former 5 id: `installation-${index}`, distanceM: index * 1000, })); - const summary = summarizeInstallationViewport(items, { available: true, stale: false }); + const summary = summarizeInstallationViewport(items, { + available: true, + stale: false, + }); assert.equal(summary.nearest.length, 10); assert.equal(summary.navigationNearest.length, 5001); assert.equal(AWARENESS_QUERY_LIMIT >= 5001, true); @@ -2646,12 +3554,14 @@ test('unknown subject cohort blocks cross-layer NEXT navigation and availability const vesselPosition = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0); const militaryPosition = Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1000); const restoreCollections = stubAwarenessCollections({ - military: [{ - icao24: 'm1', - callsign: 'M1', - position: militaryPosition, - distanceM: 5000, - }], + military: [ + { + icao24: 'm1', + callsign: 'M1', + position: militaryPosition, + distanceM: 5000, + }, + ], }); const runtime = installAwarenessRuntime({ isEnabled: (layerId) => layerId !== 'ais-live-vessels', @@ -2660,16 +3570,24 @@ test('unknown subject cohort blocks cross-layer NEXT navigation and availability let focusCalls = 0; try { - replaceMethod(militaryFlightsLayer, 'trackById', () => { - focusCalls += 1; - return true; - }, restores); + replaceMethod( + militaryFlightsLayer, + 'trackById', + () => { + focusCalls += 1; + return true; + }, + restores, + ); runtime.dispatch( 'gev:awareness-subject-selected', awarenessSubject('ais-live-vessels', 'v1', vesselPosition), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, false); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + false, + ); assert.equal(militaryAwarenessLayer.navigateNext(), false); assert.equal(focusCalls, 0); } finally { @@ -2680,24 +3598,36 @@ test('unknown subject cohort blocks cross-layer NEXT navigation and availability }); test('NEXT availability helper gates only new-target branches on an unknown cohort', () => { - assert.equal(canNavigateAwarenessNext({ - hasForwardHistory: true, - hasExpandedFlightTarget: true, - hasNearbyTarget: true, - subjectCohortUnknown: true, - }), true); - assert.equal(canNavigateAwarenessNext({ - hasExpandedFlightTarget: true, - subjectCohortUnknown: true, - }), false); - assert.equal(canNavigateAwarenessNext({ - hasNearbyTarget: true, - subjectCohortUnknown: true, - }), false); - assert.equal(canNavigateAwarenessNext({ - hasExpandedFlightTarget: true, - subjectCohortUnknown: false, - }), true); + assert.equal( + canNavigateAwarenessNext({ + hasForwardHistory: true, + hasExpandedFlightTarget: true, + hasNearbyTarget: true, + subjectCohortUnknown: true, + }), + true, + ); + assert.equal( + canNavigateAwarenessNext({ + hasExpandedFlightTarget: true, + subjectCohortUnknown: true, + }), + false, + ); + assert.equal( + canNavigateAwarenessNext({ + hasNearbyTarget: true, + subjectCohortUnknown: true, + }), + false, + ); + assert.equal( + canNavigateAwarenessNext({ + hasExpandedFlightTarget: true, + subjectCohortUnknown: false, + }), + true, + ); }); test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight feeds', () => { @@ -2705,12 +3635,14 @@ test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight subject: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 1000), contact: Cesium.Cartesian3.fromDegrees(-97.7, 30.3, 1000), }; - const military = [{ - icao24: 'm1', - callsign: 'M1', - position: positions.contact, - distanceM: 5000, - }]; + const military = [ + { + icao24: 'm1', + callsign: 'M1', + position: positions.contact, + distanceM: 5000, + }, + ]; let flightsEnabled = false; const restoreCollections = stubAwarenessCollections({ military }); const runtime = installAwarenessRuntime({ @@ -2720,16 +3652,24 @@ test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight let focusCalls = 0; try { - replaceMethod(militaryFlightsLayer, 'trackById', () => { - focusCalls += 1; - return true; - }, restores); + replaceMethod( + militaryFlightsLayer, + 'trackById', + () => { + focusCalls += 1; + return true; + }, + restores, + ); runtime.dispatch( 'gev:awareness-subject-selected', awarenessSubject('flights', 'subject', positions.subject), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, false); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + false, + ); assert.equal(militaryAwarenessLayer.navigateNext(), false); assert.equal(focusCalls, 0); @@ -2738,7 +3678,10 @@ test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight 'gev:awareness-subject-selected', awarenessSubject('flights', 'subject', positions.subject), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, true); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + true, + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(focusCalls, 1); @@ -2747,14 +3690,20 @@ test('canNext agrees with NEXT for unknown, healthy-empty, and recovered flight 'gev:awareness-subject-selected', awarenessSubject('flights', 'subject', positions.subject), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, false); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + false, + ); flightsEnabled = true; runtime.dispatch( 'gev:awareness-subject-selected', awarenessSubject('flights', 'subject', positions.subject), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, true); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + true, + ); assert.equal(militaryAwarenessLayer.navigateNext(), true); assert.equal(focusCalls, 2); } finally { @@ -2773,10 +3722,17 @@ test('far-side hidden aircraft cannot enable NEXT when navigation cannot see the try { replaceMethod(flightsLayer, 'getNearby', () => [], restores); replaceMethod(militaryFlightsLayer, 'getNearby', () => [], restores); - replaceMethod(flightsLayer, 'getAllPositions', () => [{ - id: 'hidden', - position: hiddenPosition, - }], restores); + replaceMethod( + flightsLayer, + 'getAllPositions', + () => [ + { + id: 'hidden', + position: hiddenPosition, + }, + ], + restores, + ); replaceMethod(militaryFlightsLayer, 'getAllPositions', () => [], restores); runtime.dispatch( @@ -2784,7 +3740,10 @@ test('far-side hidden aircraft cannot enable NEXT when navigation cannot see the awarenessSubject('flights', 'subject', subjectPosition), ); - assert.equal(militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, false); + assert.equal( + militaryAwarenessLayer.getContextSnapshot()?.navigation.canNext, + false, + ); assert.equal(militaryAwarenessLayer.navigateNext(), false); } finally { restores.reverse().forEach((restore) => restore()); @@ -2824,9 +3783,13 @@ test('runtime listeners exist only while the awareness layer is enabled', () => removeEventListener(type, listener) { listeners.get(type)?.delete(listener); }, - setInterval() { return nextTimer++; }, + setInterval() { + return nextTimer++; + }, clearInterval() {}, - requestAnimationFrame(callback) { return nextTimer++; }, + requestAnimationFrame(callback) { + return nextTimer++; + }, cancelAnimationFrame() {}, }; const fakeBody = new FakeElement(); @@ -2835,7 +3798,9 @@ test('runtime listeners exist only while the awareness layer is enabled', () => preRender: { addEventListener() { preRenderListeners += 1; - return () => { preRenderListeners -= 1; }; + return () => { + preRenderListeners -= 1; + }; }, }, }, @@ -2846,27 +3811,43 @@ test('runtime listeners exist only while the awareness layer is enabled', () => globalThis.window = fakeWindow; globalThis.document = { body: fakeBody, - getElementById() { return null; }, - createElement() { return new FakeElement(); }, + getElementById() { + return null; + }, + createElement() { + return new FakeElement(); + }, }; try { militaryAwarenessLayer.setParams({ passive: true }); militaryAwarenessLayer.init(viewer); - assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0); + assert.equal( + [...listeners.values()].reduce((count, set) => count + set.size, 0), + 0, + ); assert.equal(preRenderListeners, 0); militaryAwarenessLayer.enable(); - assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 4); + assert.equal( + [...listeners.values()].reduce((count, set) => count + set.size, 0), + 4, + ); assert.equal(preRenderListeners, 1); militaryAwarenessLayer.disable(); - assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0); + assert.equal( + [...listeners.values()].reduce((count, set) => count + set.size, 0), + 0, + ); assert.equal(preRenderListeners, 0); militaryAwarenessLayer.enable(); militaryAwarenessLayer.destroy(); - assert.equal([...listeners.values()].reduce((count, set) => count + set.size, 0), 0); + assert.equal( + [...listeners.values()].reduce((count, set) => count + set.size, 0), + 0, + ); assert.equal(preRenderListeners, 0); } finally { globalThis.window = originalWindow; @@ -2884,7 +3865,8 @@ test('awareness runtime parameters return a detached snapshot', () => { test('Contacts refresh cadence is snappy while the camera moves and parked otherwise', () => { assert.equal(awarenessRefreshIntervalMs(false), 750); assert.ok( - awarenessRefreshIntervalMs(true) >= 100 && awarenessRefreshIntervalMs(true) <= 200, + awarenessRefreshIntervalMs(true) >= 100 && + awarenessRefreshIntervalMs(true) <= 200, 'a moving camera earns a ~150-200 ms cadence', ); }); @@ -2903,7 +3885,12 @@ test('intermittent pose-bin crossings cannot refresh faster than the motion floo for (let frame = 0; frame < frames; frame += 1) { const nowMs = 1_000 + frame * frameMs; if (frame % 4 === 0) lastPoseChangeMs = nowMs; - const decision = awarenessRefreshDecision({ nowMs, lastRefreshMs, lastPoseChangeMs, wasMoving }); + const decision = awarenessRefreshDecision({ + nowMs, + lastRefreshMs, + lastPoseChangeMs, + wasMoving, + }); wasMoving = decision.moving; if (decision.refresh) { refreshes += 1; @@ -2911,33 +3898,61 @@ test('intermittent pose-bin crossings cannot refresh faster than the motion floo } } // 960 ms of motion against a 175 ms floor tops out at six refreshes. - const ceiling = Math.ceil((frames * frameMs) / awarenessRefreshIntervalMs(true)); + const ceiling = Math.ceil( + (frames * frameMs) / awarenessRefreshIntervalMs(true), + ); assert.ok( refreshes <= ceiling, - `intermittent bin crossings must respect the ${awarenessRefreshIntervalMs(true)} ms floor: ` - + `expected at most ${ceiling} refreshes in ${frames} frames, got ${refreshes}`, + `intermittent bin crossings must respect the ${awarenessRefreshIntervalMs(true)} ms floor: ` + + `expected at most ${ceiling} refreshes in ${frames} frames, got ${refreshes}`, ); assert.ok(refreshes >= 4, `motion must still be snappy, got ${refreshes}`); }); test('motion ends only after the pose has been unchanged through the settle window', () => { - const parked = (nowMs, lastPoseChangeMs, wasMoving = true) => awarenessRefreshDecision({ - nowMs, lastRefreshMs: nowMs - 10_000, lastPoseChangeMs, wasMoving, - }).moving; - assert.equal(parked(1_000, 1_000), true, 'the frame the pose changed is moving'); - assert.equal(parked(1_200, 1_000), true, 'a 200 ms gap between bin crossings is still moving'); - assert.equal(parked(1_249, 1_000), true, 'just inside the settle window is still moving'); - assert.equal(parked(1_250, 1_000), false, 'the settle window expiring ends motion'); + const parked = (nowMs, lastPoseChangeMs, wasMoving = true) => + awarenessRefreshDecision({ + nowMs, + lastRefreshMs: nowMs - 10_000, + lastPoseChangeMs, + wasMoving, + }).moving; + assert.equal( + parked(1_000, 1_000), + true, + 'the frame the pose changed is moving', + ); + assert.equal( + parked(1_200, 1_000), + true, + 'a 200 ms gap between bin crossings is still moving', + ); + assert.equal( + parked(1_249, 1_000), + true, + 'just inside the settle window is still moving', + ); + assert.equal( + parked(1_250, 1_000), + false, + 'the settle window expiring ends motion', + ); }); test('the motion-end settle refresh still respects the motion floor', () => { // Motion ends 20 ms after a refresh: the settle must NOT jump the floor. const tooSoon = awarenessRefreshDecision({ - nowMs: 2_000, lastRefreshMs: 1_980, lastPoseChangeMs: 1_700, wasMoving: true, + nowMs: 2_000, + lastRefreshMs: 1_980, + lastPoseChangeMs: 1_700, + wasMoving: true, }); assert.deepEqual(tooSoon, { moving: false, refresh: false }); const allowed = awarenessRefreshDecision({ - nowMs: 2_000, lastRefreshMs: 1_800, lastPoseChangeMs: 1_700, wasMoving: true, + nowMs: 2_000, + lastRefreshMs: 1_800, + lastPoseChangeMs: 1_700, + wasMoving: true, }); assert.deepEqual(allowed, { moving: false, refresh: true }); }); @@ -2971,9 +3986,18 @@ test('parked Contacts takes no continuous-render hold and requests at most the p // 1. Enabled, nothing selected, camera parked — Contacts must not be a // reason the whole scene keeps repainting. - assert.ok(!holds().includes('military-awareness'), 'enable() must not hold outright'); - for (let frame = 0; frame < 60; frame += 1) { clockMs += 16; runtime.tick(); } - assert.ok(!holds().includes('military-awareness'), 'a parked empty panel holds nothing'); + assert.ok( + !holds().includes('military-awareness'), + 'enable() must not hold outright', + ); + for (let frame = 0; frame < 60; frame += 1) { + clockMs += 16; + runtime.tick(); + } + assert.ok( + !holds().includes('military-awareness'), + 'a parked empty panel holds nothing', + ); // 2. A live subject, still parked: one ring paints, then quiet. runtime.dispatch( @@ -2981,22 +4005,37 @@ test('parked Contacts takes no continuous-render hold and requests at most the p awarenessSubject('flights', 'subject-flight', position), ); const requestsAfterSelect = runtime.renderRequests.length; - for (let frame = 0; frame < 60; frame += 1) { clockMs += 16; runtime.tick(); } - assert.ok(!holds().includes('military-awareness'), 'a parked panel holds nothing even with a subject'); + for (let frame = 0; frame < 60; frame += 1) { + clockMs += 16; + runtime.tick(); + } + assert.ok( + !holds().includes('military-awareness'), + 'a parked panel holds nothing even with a subject', + ); const parkedRequests = runtime.renderRequests.length - requestsAfterSelect; // 960 ms parked = at most two 750 ms-cadence frames' worth of requests. - assert.ok(parkedRequests <= 2, `parked Contacts requested ${parkedRequests} renders in 960 ms`); + assert.ok( + parkedRequests <= 2, + `parked Contacts requested ${parkedRequests} renders in 960 ms`, + ); // 3. Moving: the arrows are screen-projected, so per-frame work is real. clockMs += 16; runtime.viewer.camera.heading += 0.5; runtime.tick(); - assert.ok(holds().includes('military-awareness'), 'a moving view earns the hold'); + assert.ok( + holds().includes('military-awareness'), + 'a moving view earns the hold', + ); // 4. Parked again: the hold is dropped on a frame the hold itself bought. clockMs += 300; runtime.tick(); - assert.ok(!holds().includes('military-awareness'), 'settling releases the hold'); + assert.ok( + !holds().includes('military-awareness'), + 'settling releases the hold', + ); // 5. Teardown releases even without another frame. runtime.viewer.camera.heading += 0.5; @@ -3004,7 +4043,10 @@ test('parked Contacts takes no continuous-render hold and requests at most the p runtime.tick(); assert.ok(holds().includes('military-awareness')); militaryAwarenessLayer.disable(); - assert.ok(!holds().includes('military-awareness'), 'disable() drops the hold'); + assert.ok( + !holds().includes('military-awareness'), + 'disable() drops the hold', + ); } finally { Date.now = realNow; runtime.restore(); @@ -3015,17 +4057,43 @@ test('parked Contacts takes no continuous-render hold and requests at most the p test('Contacts needs continuous render only for a live cohort under a moving view', () => { const need = (input) => awarenessNeedsContinuousRender(input); - assert.equal(need({ cameraMoving: true, hasSubject: true, hasLiveResults: true }), true); - assert.equal(need({ cameraMoving: false, hasSubject: true, hasLiveResults: true }), false); - assert.equal(need({ cameraMoving: true, hasSubject: false, hasLiveResults: false }), false); - assert.equal(need({ cameraMoving: true, hasSubject: true, hasLiveResults: false }), false); + assert.equal( + need({ cameraMoving: true, hasSubject: true, hasLiveResults: true }), + true, + ); + assert.equal( + need({ cameraMoving: false, hasSubject: true, hasLiveResults: true }), + false, + ); + assert.equal( + need({ cameraMoving: true, hasSubject: false, hasLiveResults: false }), + false, + ); + assert.equal( + need({ cameraMoving: true, hasSubject: true, hasLiveResults: false }), + false, + ); }); test('empty and failed cohorts are not "live" — there is no arrow to animate', () => { - const cohorts = (...counts) => ({ cohorts: counts.map((count) => ({ summary: { count } })) }); - assert.equal(awarenessResultsAreLive(cohorts(3, 0, null)), true, 'one populated cohort is enough'); - assert.equal(awarenessResultsAreLive(cohorts(0, 0, 0)), false, 'healthy but empty feeds animate nothing'); - assert.equal(awarenessResultsAreLive(cohorts(null, null)), false, 'unavailable/stale feeds animate nothing'); + const cohorts = (...counts) => ({ + cohorts: counts.map((count) => ({ summary: { count } })), + }); + assert.equal( + awarenessResultsAreLive(cohorts(3, 0, null)), + true, + 'one populated cohort is enough', + ); + assert.equal( + awarenessResultsAreLive(cohorts(0, 0, 0)), + false, + 'healthy but empty feeds animate nothing', + ); + assert.equal( + awarenessResultsAreLive(cohorts(null, null)), + false, + 'unavailable/stale feeds animate nothing', + ); assert.equal(awarenessResultsAreLive(cohorts(0, null)), false); assert.equal(awarenessResultsAreLive({ cohorts: [] }), false); assert.equal(awarenessResultsAreLive(null), false); @@ -3057,7 +4125,11 @@ test('a moving view over failed feeds takes no continuous-render hold', () => { ); const results = militaryAwarenessLayer.getContextSnapshot(); assert.ok(results, 'a subject is selected, so an evaluation exists'); - assert.equal(awarenessResultsAreLive(results), false, 'but every cohort is empty/failed'); + assert.equal( + awarenessResultsAreLive(results), + false, + 'but every cohort is empty/failed', + ); for (let frame = 0; frame < 10; frame += 1) { clockMs += 16; @@ -3095,10 +4167,15 @@ test('a moving camera refreshes Contacts more than once inside one parked interv pitch: -0.4, roll: 0, }; - replaceMethod(flightsLayer, 'getAllPositions', () => { - refreshes += 1; - return [{ id: 'subject-flight', position }]; - }, restores); + replaceMethod( + flightsLayer, + 'getAllPositions', + () => { + refreshes += 1; + return [{ id: 'subject-flight', position }]; + }, + restores, + ); runtime.dispatch( 'gev:awareness-subject-selected', @@ -3131,7 +4208,11 @@ test('a moving camera refreshes Contacts more than once inside one parked interv clockMs += 16; runtime.tick(); } - assert.equal(refreshes, 1, 'a parked camera keeps the single-refresh-per-750 ms cadence'); + assert.equal( + refreshes, + 1, + 'a parked camera keeps the single-refresh-per-750 ms cadence', + ); } finally { Date.now = realNow; restores.reverse().forEach((restore) => restore()); @@ -3140,23 +4221,38 @@ test('a moving camera refreshes Contacts more than once inside one parked interv } }); - test('a selected installation card keeps every member name and escapes source text', () => { const runtime = installAwarenessRuntime(); const restores = []; try { - for (const layer of [aisLiveVesselsLayer, flightsLayer, militaryFlightsLayer, militaryInstallationsLayer]) + for (const layer of [ + aisLiveVesselsLayer, + flightsLayer, + militaryFlightsLayer, + militaryInstallationsLayer, + ]) replaceMethod(layer, 'getNearby', () => [], restores); militaryAwarenessLayer.setParams({ passive: false }); runtime.dispatch('gev:entity-selected', { - layerId: 'military-installations', id: 'osm:military:w1', label: 'Parent base', - latitude: 31.13, longitude: -97.78, - properties: { memberNames: ['Area one', 'Area two', 'Area three', ''] }, + layerId: 'military-installations', + id: 'osm:military:w1', + label: 'Parent base', + latitude: 31.13, + longitude: -97.78, + properties: { + memberNames: ['Area one', 'Area two', 'Area three', ''], + }, }); militaryAwarenessLayer.update(); const html = runtime.panel().innerHTML; assert.match(html, /Named areas \(4\)/); - for (const name of ['Area one', 'Area two', 'Area three', '<Area four>']) assert.ok(html.includes(name)); + for (const name of [ + 'Area one', + 'Area two', + 'Area three', + '<Area four>', + ]) + assert.ok(html.includes(name)); assert.ok(!html.includes('')); } finally { restores.reverse().forEach((restore) => restore()); diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 17886e8fa..a3e99afe1 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -84,14 +84,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('layers.awareness.reason.feedUnavailable'), + reason: t('feed unavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: t('layers.awareness.reason.feedStale'), + reason: t('feed stale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -108,8 +108,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? t('layers.awareness.reason.nearby') - : t('layers.awareness.reason.none'), + ? t('observed or mapped nearby context') + : t('no observed or mapped objects in current feeds'), }; } diff --git a/src/data/satelliteClass.js b/src/data/satelliteClass.js index 83ea0e846..f50e7d33f 100644 --- a/src/data/satelliteClass.js +++ b/src/data/satelliteClass.js @@ -17,7 +17,7 @@ * for), then the constellations users can actually name, then the grab-bag. */ -import { t } from '../i18n/index.js'; +import { t, msg } from '../i18n/index.js'; /** * Class registry. `label` is the card/legend token, `color` the point color, @@ -84,24 +84,26 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([ // translate through these keys at the presentation boundary. const CLASS_DISPLAY_KEYS = Object.freeze({ station: Object.freeze({ - label: 'layers.satellites.class.station', - blurb: 'layers.satellites.class.stationBlurb', + label: msg('STATION'), + blurb: msg('Crewed stations and their visiting vehicles'), }), nav: Object.freeze({ - label: 'layers.satellites.class.nav', - blurb: 'layers.satellites.class.navBlurb', + label: msg('NAV'), + blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'), }), geo: Object.freeze({ - label: 'layers.satellites.class.geo', - blurb: 'layers.satellites.class.geoBlurb', + label: msg('GEO'), + blurb: msg( + 'Geostationary belt — comms and weather, fixed over the equator', + ), }), visual: Object.freeze({ - label: 'layers.satellites.class.visual', - blurb: 'layers.satellites.class.visualBlurb', + label: msg('VISUAL'), + blurb: msg('Brightest naked-eye objects — CelesTrak visual group'), }), comms: Object.freeze({ - label: 'layers.satellites.class.comms', - blurb: 'layers.satellites.class.commsBlurb', + label: msg('COMMS'), + blurb: msg('Broadband constellation shell — shown only in DENSE mode'), }), }); diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 6db97e93d..079cdfbb3 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -4,7 +4,7 @@ * after the vessel cards moved out of their dedicated canvas renderer. */ -import { t } from '../i18n/index.js'; +import { t, msg } from '../i18n/index.js'; export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; /** Existing selector grid size; one ambient winner is retained per cell. */ @@ -70,25 +70,25 @@ const NUMERIC_TYPE_FAMILIES = { // machine values — TYPE_STYLES matches on them and tests pin them — so card // text translates only through this map at the presentation boundary. const TYPE_DISPLAY_KEYS = Object.freeze({ - FISHING: 'layers.vessel.type.fishing', - TOWING: 'layers.vessel.type.towing', - DREDGER: 'layers.vessel.type.dredger', - 'DIVE OPS': 'layers.vessel.type.diveOps', - MILITARY: 'layers.vessel.type.military', - SAILING: 'layers.vessel.type.sailing', - PLEASURE: 'layers.vessel.type.pleasure', - PILOT: 'layers.vessel.type.pilot', - SAR: 'layers.vessel.type.sar', - TUG: 'layers.vessel.type.tug', - 'PORT TENDER': 'layers.vessel.type.portTender', - 'ANTI-POLLUTION': 'layers.vessel.type.antiPollution', - 'LAW ENFORCE': 'layers.vessel.type.lawEnforce', - MEDICAL: 'layers.vessel.type.medical', - 'HIGH-SPEED': 'layers.vessel.type.highSpeed', - PASSENGER: 'layers.vessel.type.passenger', - CARGO: 'layers.vessel.type.cargo', - TANKER: 'layers.vessel.type.tanker', - OTHER: 'layers.vessel.type.other', + FISHING: msg('FISHING'), + TOWING: msg('TOWING'), + DREDGER: msg('DREDGER'), + 'DIVE OPS': msg('DIVE OPS'), + MILITARY: msg('MILITARY'), + SAILING: msg('SAILING'), + PLEASURE: msg('PLEASURE'), + PILOT: msg('PILOT'), + SAR: msg('SAR'), + TUG: msg('TUG'), + 'PORT TENDER': msg('PORT TENDER'), + 'ANTI-POLLUTION': msg('ANTI-POLLUTION'), + 'LAW ENFORCE': msg('LAW ENFORCE'), + MEDICAL: msg('MEDICAL'), + 'HIGH-SPEED': msg('HIGH-SPEED'), + PASSENGER: msg('PASSENGER'), + CARGO: msg('CARGO'), + TANKER: msg('TANKER'), + OTHER: msg('OTHER'), }); /** diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 55da386c3..2fe8dbc2b 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -21,7 +21,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // Choosing a mission is deliberately NOT durable suppression: picking a mission // is enthusiasm, not "never show me this again". -import { t } from './i18n/index.js'; +import { t, msg } from './i18n/index.js'; /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; @@ -39,9 +39,9 @@ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; // Catalog keys per label choice. Resolved through t() at CALL time (init), // never at module load — the locale is not resolved until main.js's body runs. const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ - ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental', - EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch', - ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents', + ENVIRONMENTAL: msg('ENVIRONMENTAL'), + EARTH_WATCH: msg('EARTH WATCH'), + ACTIVE_EVENTS: msg('ACTIVE EVENTS'), }); /** @@ -98,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ contacts: Object.freeze({ kind: 'context', contextMode: 'contacts', - busyKey: 'setup.firstRun.busy.contacts', + busyKey: msg('Starting live contacts…'), }), 'space-missions': Object.freeze({ kind: 'context', contextMode: 'space-missions', - busyKey: 'setup.firstRun.busy.spaceMissions', + busyKey: msg('Opening space missions…'), }), environmental: Object.freeze({ kind: 'globe', @@ -121,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ // before a launch. LEDGERED post-launch. Until it lands, keyless visitors // are judged on the layer row, which tells them the truth. layerIds: Object.freeze(['earthquakes', 'local-firms']), - busyKey: 'setup.firstRun.busy.environmental', + busyKey: msg('Scanning active events…'), }), explore: Object.freeze({ kind: 'none' }), }); @@ -375,9 +375,14 @@ export function initFirstRunExperience({ '[data-first-run-environmental-title] + small', ); if (environmentalSub) - environmentalSub.textContent = t('setup.firstRun.choice.environmentalSub'); + environmentalSub.textContent = t( + 'Live earthquakes and active fires, from USGS and NASA', + ); const description = root.querySelector('#first-run-description'); - if (description) description.textContent = t('setup.firstRun.description'); + if (description) + description.textContent = t( + 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', + ); const status = root.querySelector('[data-first-run-status]'); const suppressBox = root.querySelector('[data-first-run-suppress]'); @@ -461,7 +466,7 @@ export function initFirstRunExperience({ if (!status) return; if (next) status.textContent = t( - FIRST_RUN_MISSIONS[choice]?.busyKey || 'setup.firstRun.busy.working', + FIRST_RUN_MISSIONS[choice]?.busyKey || msg('Working…'), ); else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus; @@ -512,7 +517,10 @@ export function initFirstRunExperience({ Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; if (status) { status.dataset.sticky = 'true'; - status.textContent = t('setup.firstRun.status.failed', { detail }); + status.textContent = t( + 'Could not open that mission{detail}. Retry or explore manually.', + { detail }, + ); } setBusy(false); }; @@ -528,7 +536,9 @@ export function initFirstRunExperience({ if (box) box.checked = !wanted; if (!status) return; status.dataset.sticky = 'true'; - status.textContent = t('setup.firstRun.status.storageBlocked'); + status.textContent = t( + 'This browser is blocking storage, so that could not be saved.', + ); }; const keyboard = createSurfaceKeyboard({ diff --git a/src/hud.js b/src/hud.js index f251c0193..065825b73 100644 --- a/src/hud.js +++ b/src/hud.js @@ -202,15 +202,15 @@ export class IntelHUD {

${this._missionId} ${this._sensorId}
NORMAL
-
${t('cockpit.hud.summaryLabel')}
-
${t('cockpit.hud.summaryAwaiting')}
+
${t('SUMMARY')}
+
${t('Awaiting telemetry...')}
-
● ${t('cockpit.hud.recLabel')} 2026-01-01 00:00:00Z
+
● ${t('REC')} 2026-01-01 00:00:00Z
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -635,7 +635,7 @@ export class IntelHUD { */ _composeSummary() { const m = this._latestMetrics; - if (!m) return t('cockpit.hud.summaryAwaiting'); + if (!m) return t('Awaiting telemetry...'); const modeEl = document.getElementById('hud-mode'); const modeLabel = modeEl?.textContent || 'NORMAL'; diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs index 9b8997f8d..3d0272b24 100644 --- a/src/i18n/catalog.test.mjs +++ b/src/i18n/catalog.test.mjs @@ -1,198 +1,57 @@ -// Catalog parity gates: every shipped non-English locale (the es follow-up, -// then one PR per further locale) is checked against en — it may never LEAD en -// (no extra keys), every shared key must keep identical placeholder names, -// and plural entries must carry every en variant plus only categories -// Intl.PluralRules reports valid for that locale (see assertVariantSuperset -// below); the strict gate demands exact key-set equality so a forgotten -// translation cannot ship silently behind the English fallback. While English -// is the only shipped locale these loops are vacuous — the gate machinery -// stays so each locale PR meets it from day one. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { getCatalog, mergeNamespace } from './index.js'; -import { CATALOG_LOCALES } from './locale.js'; -/* - * PARITY FLIP — how the strict gate is previewed or relaxed. - * ───────────────────────────────────────────────────────────────────────────── - * REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the - * exact en key set. While a NEW catalog is still an untranslated seed that - * lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to - * the subset rules (a locale may be a subset of en, never a superset). The - * pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as - * an alias. Record every flip in docs/TRANSLATORS.md. - */ -const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY - ?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY; -const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0'; +import { + checkCatalog, + extractMessages, + readCatalog, + shippedLocales, +} from '../../scripts/i18n.mjs'; +import { LOCALES } from './locale.js'; -const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; +const messages = extractMessages(); -/** Placeholder names used by one catalog entry (string or plural variants). */ -function placeholdersOf(entry) { - const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {}); - const names = new Set(); - for (const pattern of patterns) { - if (typeof pattern !== 'string') continue; - for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]); - } - return names; -} - -/* - * PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES - * ───────────────────────────────────────────────────────────────────────────── - * en plural entries carry { one, other } — the only cardinal categories - * Intl.PluralRules reports for English. Slavic locales such as ru/uk select - * one/few/many/other, so exact-shape equality with en would forbid their - * grammars. The rule is now, per locale and shared key: - * (a) the entry must carry EVERY variant en has (they are mandatory — the - * runtime reads entry. ?? entry.other, so dropping an en - * variant strands the fallback), and - * (b) every variant added beyond en's must be a cardinal category - * Intl.PluralRules reports VALID for that locale - * (resolvedOptions().validCategories, spelled pluralCategories on - * older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en). - * en itself can never grow a variant (English's valid set is exactly - * one/other), and a typo'd variant like 'xother' fails loudly for any - * locale. The runtime degrades gracefully the other way: an entry lacking a - * category its locale would select renders entry.other (selectPluralPattern - * in src/i18n/index.js; pinned in i18n.test.mjs). - */ - -/** Cardinal plural categories Intl may select for a locale. */ -function validPluralCategories(locale) { - const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions(); - // The property was renamed pluralCategories → validCategories in newer - // ECMAScript drafts; read whichever the running ICU exposes. - const categories = validCategories ?? pluralCategories; - assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`); - return new Set(categories); -} - -/** Assert the plural-shape rule for one locale entry against its en twin. */ -function assertVariantSuperset(localeEntry, enEntry, locale, key) { - assert.equal( - typeof localeEntry, - typeof enEntry, - `variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`, - ); - if (typeof enEntry === 'string') return; - const enVariants = Object.keys(enEntry); - const variants = Object.keys(localeEntry); - const missing = enVariants.filter((variant) => !variants.includes(variant)); +test('every catalog file is a shipped locale and every non-English locale has a catalog', () => { assert.deepEqual( - missing, - [], - `${locale}:${key} must keep every en variant (${enVariants.join(',')})`, + shippedLocales().sort(), + LOCALES.filter((locale) => locale !== 'en').sort(), ); - const valid = validPluralCategories(locale); - const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant)); - assert.deepEqual( - invalid, - [], - `${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`, - ); -} - -const enKeys = Object.keys(getCatalog('en')).sort(); -// Every shipped non-en locale with its sorted key list. -const localeKeys = new Map( - CATALOG_LOCALES - .filter((locale) => locale !== 'en') - .map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]), -); - -test('every shipped locale ships a merged catalog', () => { - // English only in the foundation state; a locale PR appends its code here - // alongside its catalog files (docs/TRANSLATORS.md). - assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']); - for (const locale of CATALOG_LOCALES) { - assert.ok(getCatalog(locale), `merged catalog for ${locale}`); - } }); -test('no locale carries keys that en does not have', () => { - for (const [locale, keys] of localeKeys) { - const extras = keys.filter((key) => !enKeys.includes(key)); - assert.deepEqual( - extras, - [], - `extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`, +for (const locale of shippedLocales()) { + test(`${locale} catalog matches the code (npm run i18n)`, () => { + const { missing, stale, errors } = checkCatalog( + locale, + readCatalog(locale), + messages, ); - } -}); + assert.deepEqual( + { missing, stale, errors }, + { missing: [], stale: [], errors: [] }, + ); + }); +} -test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => { - const enCatalog = getCatalog('en'); - for (const [locale, keys] of localeKeys) { - const catalog = getCatalog(locale); - for (const key of keys) { - assert.deepEqual( - [...placeholdersOf(catalog[key])].sort(), - [...placeholdersOf(enCatalog[key])].sort(), - `placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`, - ); - assertVariantSuperset(catalog[key], enCatalog[key], locale, key); - } - } -}); - -test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => { - const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' }; - // ru selects one/few/many/other, so the full four-variant translation of - // an en { one, other } twin is a legal superset (adds only valid ru - // categories) — exactly what stage-B translators must produce… - assert.doesNotThrow(() => assertVariantSuperset( - { one: '…', few: '…', many: '…', other: '…' }, - enEntry, +test('checkCatalog reports missing, stale, placeholder and plural problems', () => { + const fixture = new Map([ + ['Saved {name}', { plural: false }], + ['{count} keys', { plural: true, one: '{count} key' }], + ['Untranslated', { plural: false }], + ]); + const result = checkCatalog( 'ru', - 'layers.clear.toast.cleared', - )); - // …while a variant Intl never selects for ru (a typo like 'xother') fails. - assert.throws( - () => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'), - /xother/, + { + 'Saved {name}': 'Сохранено {nmae}', + '{count} keys': { + one: '{count} ключ', + few: '{count} ключа', + many: '{count} ключей', + }, + Removed: 'Удалено', + }, + fixture, ); - // Dropping a mandatory en variant fails even when all additions are valid. - assert.throws( - () => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'), - /every en variant/, - ); - // String ↔ plural-object drift still fails exactly as before. - assert.throws( - () => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'), - /string ↔ plural-object/, - ); - // The gate stays honest about what the runtime can select: validCategories - // is read from Intl, not hardcoded per locale. - assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']); - assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']); -}); - -test('exact key parity for every shipped locale once translation is complete (parity flip)', () => { - if (!REQUIRE_FULL_PARITY) { - // Subset mode: every locale key resolves; en-only keys fall back silently. - for (const [locale, keys] of localeKeys) { - for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`); - } - return; - } - for (const [locale, keys] of localeKeys) { - assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`); - } -}); - -test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => { - const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } }); - assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' }); - assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/); - assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/); - assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/); - assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {}); -}); - -test('registered namespaces produce the documented dot prefixes', () => { - const prefixes = new Set(enKeys.map((key) => key.split('.')[0])); - assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']); + assert.deepEqual(result.missing, ['Untranslated']); + assert.deepEqual(result.stale, ['Removed']); + assert.equal(result.errors.length, 2); }); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 0b9ca8a23..abe1414fc 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -1,457 +1,205 @@ -// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded -// storage, English fallback, interpolation, plural selection, document -// application on all four attributes, and hash-exact reload handling. -import { test } from 'node:test'; +import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { - CATALOG_LOCALES, - DEFAULT_LOCALE, - LOCALE_METADATA, - LOCALE_STORAGE_KEY, - applyDocumentLanguage, - availableLocales, - normalizeLocale, - resolveLocale, - resolveLocalePair, - writeStoredLocale, -} from './locale.js'; + import { applyDocumentTranslations, formatDate, formatNumber, - formatRelativeTime, - getCatalog, - getLocale, - hasMessage, + msg, persistLocaleAndReload, - resolveMessage, - selectPluralPattern, setLocale, t, + tn, } from './index.js'; +import { + LOCALE_STORAGE_KEY, + applyDocumentLanguage, + normalizeLocale, + resolveLocale, + writeStoredLocale, +} from './locale.js'; -// Tests mutate module locale state; each dependent test re-pins it. -function pinLocale(locale) { - assert.equal(setLocale(locale), locale); -} +afterEach(() => setLocale('en')); -test('normalizeLocale folds regional variants and rejects unsupported tags', () => { - assert.equal(normalizeLocale('es'), 'es'); - assert.equal(normalizeLocale('ES'), 'es'); - assert.equal(normalizeLocale('es-MX'), 'es'); - assert.equal(normalizeLocale('es_419'), 'es'); - assert.equal(normalizeLocale('en'), 'en'); - assert.equal(normalizeLocale('en-GB'), 'en'); - assert.equal(normalizeLocale('fr'), 'fr'); - assert.equal(normalizeLocale('FR'), 'fr'); - assert.equal(normalizeLocale('fr-CA'), 'fr'); - assert.equal(normalizeLocale('fr_FR'), 'fr'); - assert.equal(normalizeLocale('ru'), 'ru'); - assert.equal(normalizeLocale('RU'), 'ru'); - assert.equal(normalizeLocale('ru-RU'), 'ru'); - assert.equal(normalizeLocale('ru_KZ'), 'ru'); - assert.equal(normalizeLocale('uk'), 'uk'); - assert.equal(normalizeLocale('UK'), 'uk', 'uppercase country spelling still folds to the language tag'); - assert.equal(normalizeLocale('uk-UA'), 'uk'); - assert.equal(normalizeLocale('uk_UA'), 'uk'); - assert.equal(normalizeLocale('de'), null, 'no shipped de catalog'); - assert.equal(normalizeLocale('english'), null); - assert.equal(normalizeLocale(''), null); - assert.equal(normalizeLocale(null), null); - assert.equal(normalizeLocale(42), null); -}); - -test('resolution precedence: ?lang= is one-shot, pair-scoped, and never persisted', () => { - // English is the only shipped catalog in the foundation state, so the - // offered pair is en-only: an override naming any other locale — a - // planned-but-unshipped code ('es') or garbage ('de') — is ignored while - // the rest of the chain keeps looking. The override-beats-everything pin - // comes back with the first secondary-locale PR (docs/TRANSLATORS.md). - const storage = { getItem: () => 'en', setItem() { throw new Error('must not be written'); } }; - assert.equal(resolveLocale({ - location: { search: '?lang=es' }, - storage, - languages: ['en-US'], - }), 'en'); - // A garbage override is ignored — the chain keeps looking. - assert.equal(resolveLocale({ - location: { search: '?lang=de' }, - storage, - languages: ['en-US'], - }), 'en'); - // A locale code that normalizes but has no shipped catalog is equally - // ignored: fr cannot be offered before its locale PR lands. - assert.equal(resolveLocale({ - location: { search: '?lang=fr' }, - storage, - languages: ['en-US'], - }), 'en'); -}); - -test('resolution precedence: a stored preference is re-checked against the offered pair', () => { - // 'es' may have been stored under a pair that once offered it; while - // English is the only shipped catalog the stored value is out-of-pair and - // reads as absent, so the navigator list decides — the stored preference - // degrades gracefully instead of rendering English under an es . - assert.equal(resolveLocale({ - location: { search: '' }, - storage: { getItem: () => 'es' }, - languages: ['en-US', 'en'], - }), 'en'); -}); - -test('resolution precedence: navigator languages resolve before the English default', () => { - // Regional variants normalize to the primary tag and only then face the - // pair check: 'en-GB' resolves through the offered pair, unshipped codes - // ('fr-CA', 'es-MX') defer to the next step. - assert.equal(resolveLocale({ - location: { search: '' }, - storage: { getItem: () => 'xx' }, - languages: ['en-GB'], - }), 'en'); - assert.equal(resolveLocale({ - location: { search: '' }, - storage: { getItem: () => 'xx' }, - languages: ['fr-CA', 'es-MX'], - }), 'en', 'no shipped fr/es catalog: neither navigator entry resolves'); - assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: ['fr-CA'] }), 'en'); - assert.equal(resolveLocale({ location: null, storage: null, languages: null }), DEFAULT_LOCALE); -}); - -test('guarded storage: a throwing store (private mode / quota) never escapes', () => { - const hostile = { - getItem() { throw new Error('SecurityError'); }, - setItem() { throw new Error('SecurityError'); }, - }; - assert.equal(resolveLocale({ location: { search: '' }, storage: hostile, languages: ['es'] }), 'en', - 'an unshipped navigator locale defers to the en-only default'); - assert.equal(writeStoredLocale('es', hostile), false); - assert.equal(writeStoredLocale('es', { setItem() { throw new Error('quota'); } }), false); - assert.equal(writeStoredLocale('es', {}), false, 'no setItem function means no write'); - assert.equal(writeStoredLocale('es-AR', { setItem() {} }), true, 'stored value is normalized'); -}); - -test('applyDocumentLanguage reflects lang and dir from locale metadata', () => { - const makeDoc = () => ({ documentElement: { lang: '', dir: '' } }); - const enDoc = makeDoc(); - assert.equal(applyDocumentLanguage(enDoc, 'en'), true); - assert.deepEqual({ lang: enDoc.documentElement.lang, dir: enDoc.documentElement.dir }, - { lang: 'en', dir: LOCALE_METADATA.en.dir }); - assert.equal(LOCALE_METADATA.en.dir, 'ltr', 'English is LTR by metadata'); - const esDoc = makeDoc(); - assert.equal(applyDocumentLanguage(esDoc, 'es-MX'), true); - assert.deepEqual({ lang: esDoc.documentElement.lang, dir: esDoc.documentElement.dir }, - { lang: 'es', dir: LOCALE_METADATA.es.dir }); - assert.equal(LOCALE_METADATA.es.dir, 'ltr', 'Spanish is LTR by metadata'); - const frDoc = makeDoc(); - assert.equal(applyDocumentLanguage(frDoc, 'fr-CA'), true); - assert.deepEqual({ lang: frDoc.documentElement.lang, dir: frDoc.documentElement.dir }, - { lang: 'fr', dir: LOCALE_METADATA.fr.dir }); - assert.equal(LOCALE_METADATA.fr.dir, 'ltr', 'French is LTR by metadata'); - const ruDoc = makeDoc(); - assert.equal(applyDocumentLanguage(ruDoc, 'ru-RU'), true); - assert.deepEqual({ lang: ruDoc.documentElement.lang, dir: ruDoc.documentElement.dir }, - { lang: 'ru', dir: LOCALE_METADATA.ru.dir }); - assert.equal(LOCALE_METADATA.ru.dir, 'ltr', 'Russian is LTR by metadata'); - const ukDoc = makeDoc(); - assert.equal(applyDocumentLanguage(ukDoc, 'uk_UA'), true); - assert.deepEqual({ lang: ukDoc.documentElement.lang, dir: ukDoc.documentElement.dir }, - { lang: 'uk', dir: LOCALE_METADATA.uk.dir }); - assert.equal(LOCALE_METADATA.uk.dir, 'ltr', 'Ukrainian is LTR by metadata'); - assert.equal(applyDocumentLanguage(null, 'es'), false); - assert.equal(applyDocumentLanguage({ documentElement: null }, 'es'), false); - // An unsupported locale still yields valid metadata, never an empty dir. - const fallbackDoc = makeDoc(); - applyDocumentLanguage(fallbackDoc, 'de'); - assert.equal(fallbackDoc.documentElement.lang, DEFAULT_LOCALE); - assert.equal(fallbackDoc.documentElement.dir, 'ltr'); -}); - -test('t() resolves seed keys, interpolates named placeholders, and keeps unknown ones literal', () => { - pinLocale('en'); - assert.equal(t('shell.title.subtitle'), 'NO PLACE LEFT BEHIND'); - assert.equal( - t('shell.loading.status.tilesUnavailable', { detail: '403 from ion' }), - 'Google 3D Tiles unavailable (403 from ion). Loading the keyless globe...', - ); - assert.equal( - t('shell.loading.status.tilesUnavailable', {}), - 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', - 'an unprovided name stays visible instead of silently vanishing', - ); -}); - -test('t() selects one/other plural variants through Intl.PluralRules', () => { - pinLocale('en'); - assert.equal(t('layers.clear.toast.cleared', { count: 1 }), 'Cleared 1 data layer'); - assert.equal(t('layers.clear.toast.cleared', { count: 3 }), 'Cleared 3 data layers'); - assert.equal(t('layers.clear.toast.notCleared', { count: 1 }), - '1 data layer could not be cleared'); - // Non-en plural-category selection is pinned per locale in that locale's - // PR (es: one/other; ru/uk: one/few/many/other) — with real catalog values, - // not synthetic ones. The selection CONTRACT itself stays pinned below - // through the synthetic missing-category fallback under 'ru'. -}); - -test('a missing key returns the key itself and never warns outside dev builds', () => { - pinLocale('en'); - const warnings = []; - const originalWarn = console.warn; - console.warn = (...args) => warnings.push(args); - try { - assert.equal(t('shell.definitely.not.here'), 'shell.definitely.not.here'); - assert.equal(hasMessage('shell.definitely.not.here'), false); - assert.equal(hasMessage('shell.title.subtitle'), true); - } finally { - console.warn = originalWarn; - } - // import.meta.env is absent under node:test, so the DEV gate keeps the - // console quiet here; the warn path itself only runs on the dev server. - assert.deepEqual(warnings, []); -}); - -test('a key missing in es resolves through the English fallback (pure lookup)', () => { - // The shipped es seed mirrors en key-for-key, so the fallback path is - // exercised through the pure resolver with synthetic catalogs — exactly the - // shape stage-3 leaves behind while es translation lags. - const catalogs = { - es: Object.freeze({}), - en: Object.freeze({ 'a.b': 'EN ONLY' }), - }; - assert.equal(resolveMessage(catalogs, 'es', 'a.b'), 'EN ONLY'); - assert.equal(resolveMessage(catalogs, 'fr', 'a.b'), 'EN ONLY', 'unsupported locale reads en'); - assert.equal(resolveMessage(catalogs, 'es', 'nope'), undefined); - assert.equal(resolveMessage(null, 'es', 'a.b'), undefined, 'no catalogs means no message'); -}); - -test('Intl formatters follow the active locale', () => { - pinLocale('en'); - // Five significant digits: Spanish (CLDR minimumGroupingDigits=2) only - // groups from five digits up, so a 4-digit value would hide the difference. - assert.equal(formatNumber(12345.6), '12,345.6'); - assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), - 'January 15, 2026'); - assert.equal(formatRelativeTime(-5, 'minute'), '5 minutes ago'); - pinLocale('es'); - assert.equal(formatNumber(12345.6), '12.345,6'); - assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), - '15 de enero de 2026'); - assert.equal(formatRelativeTime(-5, 'minute'), 'hace 5 minutos'); - // Formatter cache is keyed per locale: switching back must not leak es forms. - pinLocale('en'); - assert.equal(formatNumber(12345.6), '12,345.6'); -}); - -test('persistLocaleAndReload stores the choice, keeps the hash exact, strips ?lang', () => { - const writes = []; - const navigations = []; - const storage = { setItem(key, value) { writes.push([key, value]); } }; - const location = { - href: 'http://localhost:4173/?lang=es&welcome=1#cam=30.2672,-97.7431,alt=2500', - assign(url) { navigations.push(url); }, - }; - assert.equal(persistLocaleAndReload('es-MX', { location, storage }), true); - assert.deepEqual(writes, [[LOCALE_STORAGE_KEY, 'es']], 'normalized value under the versioned key'); - assert.equal(navigations.length, 1); - assert.equal(navigations[0], 'http://localhost:4173/?welcome=1#cam=30.2672,-97.7431,alt=2500'); - - // No ?lang present: the URL round-trips unchanged, hash included. - navigations.length = 0; - persistLocaleAndReload('en', { location: { href: 'http://localhost:4173/#scene=coast', assign: (u) => navigations.push(u) } }); - assert.deepEqual(navigations, ['http://localhost:4173/#scene=coast']); - - // A blocked store must not block the reload. - const blocked = { setItem() { throw new Error('SecurityError'); } }; - navigations.length = 0; - assert.equal(persistLocaleAndReload('es', { location: { href: 'http://x/?lang=es#a', assign: (u) => navigations.push(u) }, storage: blocked }), true); - assert.deepEqual(navigations, ['http://x/#a']); - - // Nothing navigable injected: no throw, no reload claim. - assert.equal(persistLocaleAndReload('es', { location: null }), false); - assert.equal(persistLocaleAndReload('es', { location: {} }), false); - - // Real-browser shape: a reloadable location MUST force the reload — assign() - // to a byte-identical URL never navigates, which stranded stored-locale - // switches (no ?lang present, hash unchanged). - const reloaded = []; - const assigned = []; - const replaced = []; - assert.equal(persistLocaleAndReload('es', { - location: { - href: 'http://localhost:4173/#scene=coast', - assign: (u) => assigned.push(u), - reload: () => reloaded.push(true), - }, - history: { replaceState: (_s, _t, u) => replaced.push(u) }, - }), true); - assert.deepEqual(replaced, ['http://localhost:4173/#scene=coast'], '?lang-free URL passes through replaceState'); - assert.equal(reloaded.length, 1, 'reload is forced'); - assert.equal(assigned.length, 0, 'assign is not used when reload exists'); -}); - -test('a plural entry lacking a category the active locale selects degrades to other (pure selection)', () => { - // Graceful degradation stays pinned after the ru/uk translation landed: - // full-parity catalogs can no longer carry a partial entry, so the - // runtime contract — entry[category] ?? entry.other in selectPluralPattern - // (src/i18n/index.js) — is exercised with a synthetic { one, other } twin - // under ru, which selects few/many for 2–4/5–20. This is the runtime half - // of the plural-superset gate in catalog.test.mjs. - const twin = Object.freeze({ one: 'ONE {count}', other: 'OTHER {count}' }); - pinLocale('ru'); - assert.equal(selectPluralPattern(twin, { count: 2 }, 'demo.key'), 'OTHER {count}', - "ru selects 'few' for 2 and the entry does not carry it: entry.other renders"); - assert.equal(selectPluralPattern(twin, { count: 5 }, 'demo.key'), 'OTHER {count}', - "ru selects 'many' for 5 and the entry does not carry it: entry.other renders"); - assert.equal(selectPluralPattern(twin, { count: 21 }, 'demo.key'), 'ONE {count}', - "21 selects 'one', which the entry carries directly"); - assert.equal(selectPluralPattern(twin, { count: 1.5 }, 'demo.key'), 'OTHER {count}', - 'fractional counts select other'); - assert.equal(selectPluralPattern(twin, {}, 'demo.key'), 'OTHER {count}', - 'a non-finite count degrades to entry.other ?? entry.one'); - pinLocale('en'); -}); - -class FakeElement { - constructor(attributes) { - this.attributes = new Map(Object.entries(attributes)); - this.textContent = ''; - } - getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } - setAttribute(name, value) { this.attributes.set(name, String(value)); } -} - -function fakeDocumentFor(elements) { +function memoryStorage(initial = {}) { + const data = new Map(Object.entries(initial)); return { - querySelectorAll(selector) { - const match = /^\[([a-z0-9-]+)\]$/.exec(selector); - if (!match) return []; - return elements.filter((element) => element.getAttribute(match[1]) !== null); - }, + getItem: (key) => (data.has(key) ? data.get(key) : null), + setItem: (key, value) => data.set(key, String(value)), + data, }; } -test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => { - pinLocale('en'); - const elements = [ - new FakeElement({ 'data-i18n': 'shell.title.subtitle' }), - new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }), - new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }), - new FakeElement({ 'data-i18n-placeholder': 'setup.keySetup.title' }), - new FakeElement({ 'data-i18n': 'shell.missing.key' }), - new FakeElement({ 'data-i18n': '' }), - ]; - const applied = applyDocumentTranslations(fakeDocumentFor(elements)); - assert.equal(applied, 4); - assert.equal(elements[0].textContent, 'NO PLACE LEFT BEHIND'); - assert.equal(elements[1].getAttribute('title'), 'Turn off all selected data layers'); - assert.equal(elements[2].getAttribute('aria-label'), 'Copy share link'); - assert.equal(elements[3].getAttribute('placeholder'), 'Power up the globe'); - // A key that resolves nowhere leaves the element untouched. - assert.equal(elements[4].textContent, ''); - assert.equal(elements[5].textContent, ''); - assert.equal(applyDocumentTranslations(null), 0); - assert.equal(applyDocumentTranslations({}), 0); +test('normalizeLocale folds regional variants and rejects unshipped tags', () => { + assert.equal(normalizeLocale('ru-RU'), 'ru'); + assert.equal(normalizeLocale(' RU '), 'ru'); + assert.equal(normalizeLocale('en_GB'), 'en'); + assert.equal(normalizeLocale('xx'), null); + assert.equal(normalizeLocale(undefined), null); }); -test('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => { - assert.deepEqual([...CATALOG_LOCALES], ['en'], - 'the foundation ships English only; each locale PR appends its code here'); - for (const locale of CATALOG_LOCALES) { - const catalog = getCatalog(locale); - assert.ok(catalog, `catalog for ${locale}`); - assert.ok(Object.keys(catalog).length > 0); - for (const key of Object.keys(catalog)) { - assert.match(key, /^(shell|cockpit|layers|setup)\./, `${locale} key ${key}`); - } - } - // Codes without a shipped catalog stay null even though they normalize - // (locale-code hygiene): shipping is catalog-driven. An unknown locale - // stays null too. - assert.equal(getCatalog('es'), null); - assert.equal(getCatalog('fr'), null); - assert.equal(getCatalog('ru'), null); - assert.equal(getCatalog('uk'), null); - assert.equal(getCatalog('de'), null); - assert.equal(getLocale(), 'en', 'state survived the whole file'); -}); - -test('locale pair: built-in and configured pairs degrade to en-only while only English ships', () => { - // English is the only shipped catalog, so the offered set is ['en'] under - // any configuration — a pair naming anything else names an unshipped - // locale. The dedup-ordering pin (default, secondary, then en as the - // always-shipped fallback) returns with the first secondary-locale PR. - assert.deepEqual([...availableLocales()], ['en'], 'no config = built-in en-only pair'); - assert.deepEqual([...availableLocales({ defaultLocale: 'fr', secondaryLocale: 'en' })], ['en'], - 'fr has no shipped catalog: the pair degenerates'); - // Regional spellings still normalize before validation. - assert.deepEqual([...availableLocales({ defaultLocale: 'FR-fr', secondaryLocale: 'EN' })], ['en']); - // A blank field means "unset" (empty .env line) and takes its built-in default. - assert.deepEqual([...availableLocales({ defaultLocale: ' ' })], ['en']); - const pair = resolveLocalePair(); - assert.equal(pair.defaultLocale, 'en'); - assert.equal(pair.secondaryLocale, 'en'); - assert.ok(Object.isFrozen(pair) && Object.isFrozen(pair.availableLocales)); -}); - -test('locale pair: a configured pair naming an unshipped locale degrades to English resolution', () => { - const config = { defaultLocale: 'fr', secondaryLocale: 'en' }; - // fr normalizes but has no shipped catalog: the pair is unusable, the - // built-in en-only pair applies, and every resolution step yields English. - assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config }), 'en'); - assert.equal(resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'fr' }, languages: null, config }), 'en'); - assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['fr-FR'], config }), 'en'); - assert.equal(resolveLocale({ location: { search: '?lang=en' }, storage: { getItem: () => 'en' }, languages: null, config }), 'en'); -}); - -test('locale pair: ?lang= cannot offer a locale without a shipped catalog', () => { +test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => { + const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' }); assert.equal( - resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }), + resolveLocale({ + location: { search: '?lang=en' }, + storage, + languages: ['ru'], + }), 'en', - 'fr has no catalog: the override falls through to the stored preference', + ); + assert.equal( + resolveLocale({ location: { search: '' }, storage, languages: ['en'] }), + 'ru', ); assert.equal( resolveLocale({ - location: { search: '?lang=es' }, - storage: { getItem: () => 'en' }, - languages: null, - config: { defaultLocale: 'es', secondaryLocale: 'fr' }, + location: { search: '?lang=xx' }, + storage: memoryStorage(), + languages: ['de-DE', 'ru-RU'], + }), + 'ru', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: memoryStorage(), + languages: ['de'], }), 'en', - 'a configured pair cannot smuggle an unshipped locale into the offered set', ); }); -test('locale pair: a stored preference outside the offered pair falls through', () => { - // A stored 'fr'/'es' reads as absent while those locales are unshipped; - // resolution defers to the navigator list and finally the built-in default. - assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en'); - assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en', - '…down to the default'); +test('a throwing storage never escapes', () => { + const storage = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + assert.equal( + resolveLocale({ location: { search: '' }, storage, languages: [] }), + 'en', + ); + assert.equal(writeStoredLocale('ru', storage), false); }); -test('locale pair: invalid, unshipped, or degenerate values fall back to the built-in en-only pair (dev-warn only)', () => { - const warnings = []; - const originalWarn = console.warn; - console.warn = (...args) => warnings.push(args); - try { - const badPairs = [ - { defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code - { defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice - { defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only - { defaultLocale: 'english', secondaryLocale: 'ES' }, - { defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog - ]; - for (const bad of badPairs) { - const pair = resolveLocalePair(bad); - assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad)); - assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad)); - assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad)); - assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en'); - } - } finally { - console.warn = originalWarn; - } - // import.meta.env is absent under node:test, so the DEV gate keeps the - // console quiet here; the warn fires only on the dev server. - assert.deepEqual(warnings, []); +test('applyDocumentLanguage sets lang and dir', () => { + const doc = { documentElement: {} }; + applyDocumentLanguage(doc, 'ru-RU'); + assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' }); +}); + +test('t() returns English text in English and translates in Russian', () => { + assert.equal(t('POWERED UP'), 'POWERED UP'); + setLocale('ru'); + assert.notEqual(t('POWERED UP'), 'POWERED UP'); +}); + +test('t() interpolates named values and keeps unknown names literal', () => { + assert.equal( + t('Save failed: {detail}', { detail: 'disk full' }), + 'Save failed: disk full', + ); + assert.equal( + t('Hello {who} {missing}', { who: 'pilot' }), + 'Hello pilot {missing}', + ); +}); + +test('t() falls back to English for text without a translation', () => { + setLocale('ru'); + assert.equal(t('No such message {n}', { n: 1 }), 'No such message 1'); +}); + +test('tn() picks English and Russian plural forms', () => { + const label = (count) => + tn( + count, + 'POWER UP · {count} KEY WAITING', + 'POWER UP · {count} KEYS WAITING', + ); + assert.equal(label(1), 'POWER UP · 1 KEY WAITING'); + assert.equal(label(3), 'POWER UP · 3 KEYS WAITING'); + setLocale('ru'); + assert.match(label(1), / 1 КЛЮЧ(?![А-ЯЁ])/); + assert.match(label(3), / 3 КЛЮЧА(?![А-ЯЁ])/); + assert.match(label(5), / 5 КЛЮЧЕЙ(?![А-ЯЁ])/); + assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/); +}); + +test('msg() returns its text unchanged', () => { + assert.equal(msg('Live Flights'), 'Live Flights'); +}); + +test('Intl formatters follow the active locale', () => { + const date = new Date(Date.UTC(2026, 0, 15)); + const options = { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }; + assert.equal(formatDate(date, options), 'January 15, 2026'); + assert.equal(formatNumber(1234.5), '1,234.5'); + setLocale('ru'); + assert.equal(formatDate(date, options), '15 января 2026 г.'); + assert.equal(formatNumber(1234.5), '1 234,5'); +}); + +function fakeElement({ text = '', attributes = {} } = {}) { + const attrs = new Map(Object.entries(attributes)); + return { + textContent: text, + getAttribute: (name) => (attrs.has(name) ? attrs.get(name) : null), + setAttribute: (name, value) => attrs.set(name, value), + attrs, + }; +} + +test('applyDocumentTranslations translates opted-in text and attributes in place', () => { + const label = fakeElement({ text: '\n POWERED\n UP ' }); + const pin = fakeElement({ attributes: { title: 'POWERED UP' } }); + const unknown = fakeElement({ text: 'No such message' }); + const doc = { + querySelectorAll(selector) { + if (selector === '[data-i18n]') return [label, unknown]; + if (selector === '[data-i18n-title]') return [pin]; + return []; + }, + }; + assert.equal( + applyDocumentTranslations(doc), + 0, + 'English leaves markup alone', + ); + setLocale('ru'); + assert.equal(applyDocumentTranslations(doc), 2); + assert.equal(label.textContent, t('POWERED UP')); + assert.equal(pin.attrs.get('title'), t('POWERED UP')); + assert.equal(unknown.textContent, 'No such message'); +}); + +test('persistLocaleAndReload stores the choice, keeps the hash, strips ?lang', () => { + const storage = memoryStorage(); + const calls = []; + const location = { + href: 'https://gev.local/?lang=en&x=1#scene=a', + assign: (url) => calls.push(['assign', url]), + reload: () => calls.push(['reload']), + }; + const history = { + replaceState: (_s, _t, url) => calls.push(['replace', url]), + }; + assert.equal( + persistLocaleAndReload('ru', { location, history, storage }), + true, + ); + assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'ru'); + assert.deepEqual(calls, [ + ['replace', 'https://gev.local/?x=1#scene=a'], + ['reload'], + ]); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 3d38b3f93..3f8f4cf4d 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,93 +1,32 @@ -// God's Eye View i18n core (phase 1): catalog registry, translation, and -// Intl-based formatting. English is the source/fallback catalog; a missing key -// in another locale falls back to English with a development-only warning. -// English is also the ONLY shipped catalog in this foundation change — each -// additional locale lands as a stacked follow-up PR following the recipe in -// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry). -// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial -// and the registry stays declarative — but only locales in the configured pair -// are offered/accepted (see locale.js resolveLocalePair). +// God's Eye View i18n: the English source text is the message key. // -// APPEND-ONLY NAMESPACE REGISTRATION -// ───────────────────────────────────────────────────────────────────────────── -// A worker adding a namespace creates locales//.js (exporting -// NAMESPACE plus a default message map of namespace-relative keys), then -// appends ONE import line per locale and pushes the module into that locale's -// array below. Nothing else changes, so concurrent workers merge trivially. -// Namespace-relative keys are prefixed at registration time: shell.*, -// cockpit.*, layers.*, setup.*. +// t('Save keys') → 'Сохранить ключи' +// t('Cleared {name}', { name }) → named interpolation +// tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules +// msg('Live Flights') → marks a string kept in data +// for a later t(); returns it as is +// +// English needs no catalog. Every other locale is one flat JSON file, +// locales/.json, keyed by the English text (for tn, by the English +// plural form). A missing entry falls back to English. +// `npm run i18n:check` keeps the catalogs in step with the code. import { DEFAULT_LOCALE, normalizeLocale, writeStoredLocale, } from './locale.js'; +import ru from './locales/ru.json' with { type: 'json' }; -import * as enShell from './locales/en/shell.js'; -import * as enCockpit from './locales/en/cockpit.js'; -import * as enLayers from './locales/en/layers.js'; -import * as enSetup from './locales/en/setup.js'; - -// Append new namespace modules here (one import + one entry per locale). -const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup]; - -/** - * Prefix one namespace module's flat, namespace-relative keys with its - * declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes - * 'shell.title.subtitle'. - * @param {{NAMESPACE: string, default?: object}} namespaceModule - * @returns {Record} Frozen prefixed message map. - */ -export function mergeNamespace(namespaceModule) { - const prefix = - typeof namespaceModule?.NAMESPACE === 'string' - ? namespaceModule.NAMESPACE.trim() - : ''; - if (!/^[a-z][a-z0-9-]*$/.test(prefix)) { - throw new Error( - `i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`, - ); - } - const merged = {}; - for (const [key, value] of Object.entries(namespaceModule.default || {})) { - merged[`${prefix}.${key}`] = value; - } - return Object.freeze(merged); -} - -/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */ -function buildCatalog(namespaceModules) { - const catalog = {}; - for (const namespaceModule of namespaceModules) { - for (const [key, value] of Object.entries( - mergeNamespace(namespaceModule), - )) { - if (Object.hasOwn(catalog, key)) { - throw new Error(`i18n duplicate catalog key: ${key}`); - } - catalog[key] = value; - } - } - return Object.freeze(catalog); -} - -const CATALOGS = Object.freeze({ - [DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES), -}); - -/** - * The merged catalog for a locale (frozen; for coverage/parity tests and - * tooling — runtime lookups go through t()). - * @param {string} locale - * @returns {Record|null} - */ -export function getCatalog(locale) { - const normalized = normalizeLocale(locale); - return (normalized && CATALOGS[normalized]) || null; -} +const CATALOGS = Object.freeze({ ru }); let currentLocale = DEFAULT_LOCALE; -/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */ +/** The catalog for a locale; null for English and unknown codes. */ +export function getCatalog(locale) { + return CATALOGS[normalizeLocale(locale)] ?? null; +} + +/** Active locale. */ export function getLocale() { return currentLocale; } @@ -106,44 +45,74 @@ export function setLocale(locale) { return currentLocale; } -/** Development-only missing-key warning. Off under node:test and prod builds. */ -function warnMissingKey(key, detail) { - // Vite replaces import.meta.env at build time; under node:test it is absent, - // so warnings never fire in CI — only on the dev server. +/** Development-only warning; silent under node:test and in production. */ +function warnMissing(text) { if (import.meta.env?.DEV !== true) return; - console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`); + console.warn( + `[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`, + ); +} + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Named {placeholder} interpolation. Unknown names stay literal so a gap + * stays visible instead of silently vanishing. */ +function interpolate(pattern, values) { + if (!values) return pattern; + return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => + Object.hasOwn(values, name) ? String(values[name]) : literal, + ); +} + +function lookup(text) { + const catalog = CATALOGS[currentLocale]; + if (!catalog) return undefined; + if (Object.hasOwn(catalog, text)) return catalog[text]; + warnMissing(text); + return undefined; } /** - * Pure lookup shared by t() and the parity tests: the requested locale first, - * then the English fallback catalog. - * @param {Record} catalogs Catalog map to read from. - * @param {string} locale Locale to resolve within that map. - * @param {string} key Dot-prefixed message key. - * @returns {string|object|undefined} Raw entry, or undefined when nowhere. + * 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 + * @returns {string} */ -export function resolveMessage(catalogs, locale, key) { - const source = catalogs || CATALOGS; - const normalized = normalizeLocale(locale); - if ( - normalized && - source[normalized] && - Object.hasOwn(source[normalized], key) - ) { - return source[normalized][key]; - } - const fallback = source[DEFAULT_LOCALE]; - return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined; +export function t(text, values) { + const entry = lookup(text); + return interpolate(typeof entry === 'string' ? entry : text, values); } -/** Whether the key resolves in the active locale or the English fallback. */ -export function hasMessage(key) { - return resolveMessage(CATALOGS, currentLocale, key) !== undefined; +/** + * Translate a count-dependent message. The catalog entry for `other` holds + * one form per Intl.PluralRules category of the locale ({ one, few, many, other } + * for Russian). `{count}` is always available to the pattern. + * @param {number} count + * @param {string} one English singular, e.g. '{count} camera' + * @param {string} other English plural, e.g. '{count} cameras' + * @param {object} [values] Extra interpolation values + * @returns {string} + */ +export function tn(count, one, other, values) { + const entry = lookup(other); + const category = pluralRules(currentLocale).select(count); + let pattern; + if (entry && typeof entry === 'object') { + pattern = entry[category] ?? entry.other ?? other; + } else { + pattern = pluralRules(DEFAULT_LOCALE).select(count) === 'one' ? one : other; + } + return interpolate(pattern, { ...values, count }); +} + +/** Mark English text that is stored in data and translated later by t(). */ +export function msg(text) { + return text; } const pluralRulesCache = new Map(); -function pluralRulesFor(locale) { +function pluralRules(locale) { let rules = pluralRulesCache.get(locale); if (!rules) { rules = new Intl.PluralRules(locale); @@ -152,60 +121,10 @@ function pluralRulesFor(locale) { return rules; } -/** Select a plural variant via Intl.PluralRules for the ACTIVE locale; - * an entry lacking the selected category degrades to entry.other. Exported - * for tests: full-parity catalogs can no longer stage a partial entry, so - * the fallback is pinned synthetically (i18n.test.mjs). */ -export function selectPluralPattern(entry, values, key) { - const count = values?.count; - if (typeof count !== 'number' || !Number.isFinite(count)) { - warnMissingKey(key, 'plural entry requires a finite {count} value'); - return entry.other ?? entry.one; - } - const category = pluralRulesFor(currentLocale).select(count); - return entry[category] ?? entry.other; -} - -const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; - -/** Named {placeholder} interpolation. Unprovided names stay literal so a gap - * stays visible during development instead of silently vanishing. */ -function interpolate(pattern, values) { - if (!values || typeof pattern !== 'string') return pattern; - return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => - Object.hasOwn(values, name) ? String(values[name]) : literal, - ); -} - -/** - * Translate one dot-prefixed key in the active locale. Missing keys in a - * non-English locale fall back to English; keys missing everywhere return the - * key itself. Plural entries ({ one, other }) require a numeric `count` value. - * @param {string} key e.g. 'layers.clear.toast.cleared' - * @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' } - * @returns {string} - */ -export function t(key, values) { - const entry = resolveMessage(CATALOGS, currentLocale, key); - if (entry === undefined) { - warnMissingKey(key, `not in ${currentLocale} or en catalog`); - return key; - } - if ( - currentLocale !== DEFAULT_LOCALE && - !Object.hasOwn(CATALOGS[currentLocale] || {}, key) - ) { - warnMissingKey(key, `missing in ${currentLocale}, using en fallback`); - } - const pattern = - typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key); - return interpolate(pattern, values); -} - /* * Intl formatters are memoized per (kind, locale, options): the constructors - * are expensive and stage-3 extraction will format numbers and dates on panel - * refresh paths. The cache is cleared whenever the active locale changes. + * are expensive and panels format on refresh paths. The cache is cleared + * whenever the active locale changes. */ const formatterCache = new Map(); @@ -230,12 +149,8 @@ export function formatDate(value, options) { } /** - * Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed - * distance in `unit`, e.g. formatRelativeTime(-5, 'minute') → - * '5 minutes ago' (en) / 'hace 5 minutos' (es). - * @param {number} value Signed distance in `unit`. - * @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …). - * @param {object} [options] Intl.RelativeTimeFormat options (numeric, style). + * Locale-aware relative time (Intl.RelativeTimeFormat), e.g. + * formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'. */ export function formatRelativeTime(value, unit = 'second', options) { return cachedFormatter('relative', Intl.RelativeTimeFormat, { @@ -244,54 +159,46 @@ export function formatRelativeTime(value, unit = 'second', options) { }).format(value, unit); } -/** DOM write targets for applyDocumentTranslations: textContent plus the - * three presentation attributes vision.md requires to agree with visible - * labels. Never innerHTML — translation functions return text. */ +/** Collapse markup whitespace so a message reads the same in HTML and JS. */ +export function normalizeMarkupText(text) { + return String(text ?? '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Markup opt-in: the element's own English text or attribute is the source. + * Normal + * + * Also data-i18n-aria-label and data-i18n-placeholder. + */ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ - Object.freeze({ - attribute: 'data-i18n', - apply(element, text) { - element.textContent = text; - }, - }), - Object.freeze({ - attribute: 'data-i18n-title', - apply(element, text) { - element.setAttribute('title', text); - }, - }), - Object.freeze({ - attribute: 'data-i18n-aria-label', - apply(element, text) { - element.setAttribute('aria-label', text); - }, - }), - Object.freeze({ - attribute: 'data-i18n-placeholder', - apply(element, text) { - element.setAttribute('placeholder', text); - }, - }), + ['data-i18n', null], + ['data-i18n-title', 'title'], + ['data-i18n-aria-label', 'aria-label'], + ['data-i18n-placeholder', 'placeholder'], ]); /** - * Apply catalog text to every element carrying an i18n attribute in `doc`. - * Elements whose key resolves nowhere are left untouched (warned in dev). - * @param {Document} doc + * Translate every opted-in element in `doc`. Runs once at boot; switching + * locale reloads the page. + * @param {Document|Element} doc * @returns {number} Count of element/attribute writes performed. */ export function applyDocumentTranslations(doc) { if (typeof doc?.querySelectorAll !== 'function') return 0; + if (!CATALOGS[currentLocale]) return 0; let applied = 0; - for (const target of DOCUMENT_TRANSLATION_TARGETS) { - for (const element of doc.querySelectorAll(`[${target.attribute}]`)) { - const key = element.getAttribute?.(target.attribute); - if (!key) continue; - if (!hasMessage(key)) { - warnMissingKey(key, `attribute ${target.attribute}`); - continue; - } - target.apply(element, t(key)); + for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) { + for (const element of doc.querySelectorAll(`[${marker}]`)) { + const source = attribute + ? element.getAttribute(attribute) + : normalizeMarkupText(element.textContent); + if (!source) continue; + const text = t(source); + if (text === source) continue; + if (attribute) element.setAttribute(attribute, text); + else element.textContent = text; applied += 1; } } @@ -300,13 +207,8 @@ export function applyDocumentTranslations(doc) { /** * Persist a locale choice, then reload preserving window.location.hash exactly - * and stripping ?lang from the search string — the override is one-shot and - * must never graduate into the stored URL. Storage is best-effort: where - * storage is blocked (private mode) the preference simply will not survive. - * @param {string} locale Locale the user chose (normalized here). - * @param {object} [deps] - * @param {{href: string, assign: Function}} [deps.location] - * @param {object} [deps.storage] Injected localStorage-like store. + * 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. * @returns {boolean} true when a reload was triggered. */ export function persistLocaleAndReload( @@ -320,20 +222,16 @@ export function persistLocaleAndReload( const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; writeStoredLocale(normalized, storage); if (!location || typeof location.assign !== 'function') return false; - const stripLang = () => { - try { - const url = new URL(location.href); - url.searchParams.delete('lang'); - return url.href; - } catch { - return location.href; - } - }; - // location.assign() to the byte-identical URL performs NO navigation in - // real browsers (the common case: no ?lang present, hash unchanged), so a - // stored locale change would never reload. Strip ?lang via replaceState - // (no extra history entry) and force a real reload instead. - const target = stripLang(); + let target = location.href; + try { + const url = new URL(location.href); + url.searchParams.delete('lang'); + target = url.href; + } catch { + // Keep the current href. + } + // location.assign() to the identical URL does not navigate, so strip ?lang + // in place and force a real reload instead. try { history?.replaceState?.(null, '', target); } catch { diff --git a/src/i18n/locale.js b/src/i18n/locale.js index ea5968fea..e2489e032 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -1,249 +1,52 @@ -// Locale identity for God's Eye View (i18n phase 1). -// -// English is the default-without-configuration, the ALWAYS-shipped fallback -// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the -// only locale with a shipped catalog in this foundation change: additional -// locales land as stacked follow-up PRs, one per locale, each following the -// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is -// configured at build/dev time through GEV_DEFAULT_LOCALE / -// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here — -// pair members are validated against the shipped catalogs, so while only -// English ships the pair degenerates to en-only (a single EN button). -// This module owns ONLY locale resolution and document language metadata — -// catalogs and translation helpers live in src/i18n/index.js. +// Which locale the page runs in: ?lang= → stored choice → browser languages → +// English. Only shipped locales are ever selected. /** Durable preference key. Matches the repo convention gev::v1. */ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; -/** - * Locales with a shipped catalog. 'en' is the fallback catalog locale - * (DEFAULT_LOCALE); every entry here can be normalized and looked up, though - * only locales in the configured PAIR (see resolveLocalePair) are offered in - * the UI and accepted during resolution. A locale PR appends its code here - * alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping. - */ -export const CATALOG_LOCALES = Object.freeze(['en']); - -/** - * Locale codes normalizeLocale folds regional variants for. Kept ahead of - * shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'), - * while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR - * edits only CATALOG_LOCALES plus its catalog files, and a code listed here - * without a catalog normalizes but is never offerable or resolvable. - */ -const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']); - -/** Fallback catalog locale and no-config default: always English. */ +/** Source language and fallback. */ export const DEFAULT_LOCALE = 'en'; /** - * Built-in pair used when no env/config shapes one, or a configured one is - * unusable. English-only while English is the only shipped catalog: the - * selector degenerates to a single EN button until a secondary locale PR - * restores the built-in pair. + * Shipped locales, in selector order. Adding one: locales/.json, an + * import in index.js, and an entry here (see docs/TRANSLATORS.md). */ -const FALLBACK_PAIR = Object.freeze({ - defaultLocale: 'en', - secondaryLocale: 'en', -}); +export const LOCALES = Object.freeze(['en', 'ru']); -/** Document metadata per normalizable locale code. All of them are LTR. */ -export const LOCALE_METADATA = Object.freeze({ - en: Object.freeze({ dir: 'ltr' }), - es: Object.freeze({ dir: 'ltr' }), - fr: Object.freeze({ dir: 'ltr' }), - ru: Object.freeze({ dir: 'ltr' }), - uk: Object.freeze({ dir: 'ltr' }), -}); +/** Each locale named in its own language, for the selector. */ +export const LOCALE_NAMES = Object.freeze({ en: 'English', ru: 'Русский' }); + +/** Right-to-left locales. */ +const RTL_LOCALES = Object.freeze([]); /** - * Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk' - * and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the - * primary tag; anything unknown (or not a string) resolves to null so the - * caller's precedence chain can keep looking. Knowing a code is hygiene, not - * shipping: offering/resolving a locale additionally requires a catalog - * (CATALOG_LOCALES) — see resolveLocalePair. - * @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry). - * @returns {'en'|'es'|'fr'|'ru'|'uk'|null} + * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'. + * @returns {string|null} */ export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; - return NORMALIZABLE_LOCALES.includes(primary) ? primary : null; + return LOCALES.includes(primary) ? primary : null; } -/** - * Read the configured pair from the two client defines. Both spellings are - * replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay - * verbatim member expressions. Under node:test `import.meta.env` is absent - * and the first access throws; the catch turns that into "use the defaults". - * @returns {{defaultLocale?: string, secondaryLocale?: string}} - */ -function readEnvLocalePair() { +function queryLocale(location) { try { - return { - defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE, - secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE, - }; - } catch { - return {}; - } -} - -/** A blank string means "unset" (an empty .env line), not an invalid value. */ -function orBuiltIn(value, builtIn) { - return typeof value === 'string' && value.trim() ? value : builtIn; -} - -/** Development-only diagnostics, same gate as warnMissingKey in index.js. */ -function warnInvalidPair(rawDefault, rawSecondary) { - // Vite replaces import.meta.env at build time; under node:test it is absent, - // so warnings never fire in CI — only on the dev server. - if (import.meta.env?.DEV !== true) return; - console.warn( - `[i18n] unusable locale pair ${JSON.stringify(String(rawDefault))}/` + - `${JSON.stringify(String(rawSecondary))} — offering English only`, - ); -} - -/** - * Resolve the offered locale pair. With no argument the two client defines are - * read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in - * value); an explicit { defaultLocale, secondaryLocale } injects a pair for - * tests and embedders — injected fields never fall through to the env. - * - * SHIPPING IS CATALOG-DRIVEN: both members must name a locale in - * CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet - * merged), or a degenerate pair (the same locale twice) returns the built-in - * pair instead, and a dev-only console.warn explains why — except when the - * raw values already ARE the built-in pair, which is the normal unconfigured - * state and must stay silent. While English is the only shipped catalog the - * built-in pair is en-only, so the selector renders a single EN button. - * - * availableLocales is the deduped [default, secondary, 'en'] triple — English - * is always shippable because its catalog is the unconditional fallback. - * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] - * @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen. - */ -export function resolveLocalePair(config) { - const injected = - typeof config === 'object' && - config !== null && - (config.defaultLocale !== undefined || - config.secondaryLocale !== undefined); - const raw = injected ? config : readEnvLocalePair(); - const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale); - const rawSecondary = orBuiltIn( - raw?.secondaryLocale, - FALLBACK_PAIR.secondaryLocale, - ); - const defaultLocale = normalizeLocale(rawDefault); - const secondaryLocale = normalizeLocale(rawSecondary); - const offerable = Boolean( - defaultLocale && - secondaryLocale && - CATALOG_LOCALES.includes(defaultLocale) && - CATALOG_LOCALES.includes(secondaryLocale), - ); - if (!offerable || defaultLocale === secondaryLocale) { - // The built-in pair itself is never a misconfiguration: only a raw value - // that had to be corrected warns on the dev server. - if ( - rawDefault !== FALLBACK_PAIR.defaultLocale || - rawSecondary !== FALLBACK_PAIR.secondaryLocale - ) { - warnInvalidPair(rawDefault, rawSecondary); - } - return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale); - } - return pairOf(defaultLocale, secondaryLocale); -} - -/** Freeze the deduped pair + always-shipped English. */ -function pairOf(defaultLocale, secondaryLocale) { - const availableLocales = [ - ...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]), - ]; - return Object.freeze({ - defaultLocale, - secondaryLocale, - availableLocales: Object.freeze(availableLocales), - }); -} - -/** - * Locales the app offers and accepts, in selector order: the configured - * default, the secondary locale, then English if not already present. - * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders). - * @returns {readonly string[]} - */ -export function availableLocales(config) { - return resolveLocalePair(config).availableLocales; -} - -/** - * One-shot `?lang=` override, read from the SEARCH string only. - * - * The override is never persisted, and it must never leak into the URL hash or - * share links: it dies with the URL it arrived in (persistLocaleAndReload in - * src/i18n/index.js strips it when a choice is stored). An unsupported value — - * or a shipped locale outside the configured pair — is ignored rather than - * forced to English; the rest of the precedence chain still gets to speak. - * @param {{search?: string}|null} location - * @param {readonly string[]} availableLocales Resolved pair locales. - * @returns {string|null} - */ -function queryLocaleOverride(location, availableLocales) { - const search = location?.search; - if (!search) return null; - try { - return normalizeIfAvailable( - new URLSearchParams(search).get('lang'), - availableLocales, - ); + return normalizeLocale(new URLSearchParams(location?.search).get('lang')); } catch { return null; } } -/** Normalize a candidate, keeping it only when the pair offers that locale. */ -function normalizeIfAvailable(candidate, availableLocales) { - const normalized = normalizeLocale(candidate); - return normalized && availableLocales.includes(normalized) - ? normalized - : null; -} - -/* - * STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js): - * the `globalThis.localStorage` getter itself can throw SecurityError in Safari - * private mode or under enterprise policies, so it is only touched inside a - * try/catch at the moment of use — never in a default parameter. An injected - * `storage` (tests, embedders) short-circuits the global entirely; `undefined` - * means "use the global". - */ - -/** Read the stored preference, treating every failure as "nothing stored". - * A stored locale outside the current pair reads as absent. */ -function readStoredLocale(storage, availableLocales) { +function readStoredLocale(storage) { try { const store = storage !== undefined ? storage : globalThis.localStorage; - return normalizeIfAvailable( - store?.getItem?.(LOCALE_STORAGE_KEY), - availableLocales, - ); + return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY)); } catch { return null; } } -/** - * Write the stored preference, best-effort. Never throws. The stored value is - * any known locale code (pair membership is re-checked at resolution time, so - * a choice made under one configuration — or for a locale whose catalog has - * not shipped yet — degrades gracefully to the pair's default). - * @returns {boolean} true only if the value actually landed. - */ +/** Store the choice; false when storage is unavailable. */ export function writeStoredLocale(locale, storage) { const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; try { @@ -252,63 +55,33 @@ export function writeStoredLocale(locale, storage) { store.setItem(LOCALE_STORAGE_KEY, normalized); return true; } catch { - // Privacy-restricted or quota-exhausted storage must never break the app. return false; } } -/** - * Resolve the active locale. Precedence (docs/TRANSLATORS.md): - * - * ?lang= → stored preference → navigator.languages → configured default - * - * Every step only accepts locales in the configured pair (which always - * includes English); each step that yields nothing (absent or out-of-pair) - * defers to the next. Without env configuration the built-in pair is en-only - * (English is the only shipped catalog), so every step resolves English — - * byte-identical to the pre-configuration resolution chain. - * @param {object} [input] - * @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from. - * @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global. - * @param {readonly string[]|null} [input.languages] Browser language list. - * @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines. - * @returns {string} - */ +/** Resolve the boot locale. Every input is injectable for tests. */ export function resolveLocale({ location = globalThis.location, storage, languages = globalThis.navigator?.languages, - config, } = {}) { - const { availableLocales: offered, defaultLocale } = - resolveLocalePair(config); - const override = queryLocaleOverride(location, offered); - if (override) return override; - const stored = readStoredLocale(storage, offered); + const fromQuery = queryLocale(location); + if (fromQuery) return fromQuery; + const stored = readStoredLocale(storage); if (stored) return stored; - if (Array.isArray(languages)) { - for (const candidate of languages) { - const normalized = normalizeIfAvailable(candidate, offered); - if (normalized) return normalized; - } + for (const candidate of Array.isArray(languages) ? languages : []) { + const normalized = normalizeLocale(candidate); + if (normalized) return normalized; } - return defaultLocale; + return DEFAULT_LOCALE; } -/** - * Reflect the active locale on the document: `` and `` - * must always agree with the language actually being rendered. - * @param {Document} doc Document whose documentElement carries the metadata. - * @param {string} locale Locale to apply (normalized here). - * @returns {boolean} true when the metadata was written. - */ +/** Set and . */ export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) { const root = doc?.documentElement; if (!root) return false; const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; root.lang = normalized; - root.dir = ( - LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE] - ).dir; + root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr'; return true; } diff --git a/src/i18n/locales/en/cockpit.js b/src/i18n/locales/en/cockpit.js deleted file mode 100644 index 89ebbeba0..000000000 --- a/src/i18n/locales/en/cockpit.js +++ /dev/null @@ -1,562 +0,0 @@ -// English catalog — cockpit namespace (phase-1 seed). -// -// Owns the first-person cockpit HUD, its Contact summary, and the briefing -// carousel. Keys are namespace-relative; the registry prefixes them with -// `cockpit.`. -export const NAMESPACE = 'cockpit'; - -export default { - // index.html #cockpit-hud section aria-label - 'hud.sectionLabel': 'Aircraft cockpit view', - // index.html #map-view-switch button label - 'exit.label': 'EXIT COCKPIT', - // index.html .cockpit-readout-label - 'readout.groundSpeed': 'GROUND SPEED', - 'readout.altitude': 'ALTITUDE', - // index.html .cockpit-context-kicker / #cockpit-context-subject (initial) - 'context.kicker': 'CONTACT', - 'context.subjectWindow': 'CONTACTS · 250 KM', - // index.html #cockpit-brief-kicker / #cockpit-brief-subtitle (initial) - 'brief.kicker': 'LIVE SIGNALS', - 'brief.subtitle': 'OBSERVED / MAPPED PINGS', - // index.html #cockpit-vision-current small label - 'vision.current': 'CURRENT', - // index.html #cockpit-radio-station (initial) - 'radio.station.ready': 'READY', - - // ── Phase-2 static markup extraction (index.html), appended ────────────── - // The shell worker owns ALL index.html static markup in phase 2 - // (docs/TRANSLATORS.md cross-surface rule); these keys land here because - // src/ui.js and src/hud.js own the surfaces' runtime half in phase 3. - - // index.html #style-indicator / #clean-view-exit (visual-style chrome) - 'presets.activeStyleLabel': 'ACTIVE STYLE', - 'display.cleanViewExitTitle': 'Return UI controls', - 'display.cleanViewExitLabel': 'EXIT CLEAN VIEW', - - // index.html control panel — Visual Presets dock tray - 'presets.toggleAriaLabel': 'Expand Visual Presets', - // Key-only: the .panel-title node shares its element with the dock-label-icon - // span, so a data-i18n write would wipe the icon; phase 3 wires this via t(). - 'presets.title': 'VISUAL PRESETS', - 'presets.pinAriaLabel': 'Pin visual presets', - 'presets.pinTitle': 'Keep visual presets open', - 'presets.styleNormalTitle': 'Show the globe without a visual filter.', - 'presets.styleNormalLabel': 'Normal', - 'presets.styleCrtTitle': - 'Emulate a green phosphor CRT with scanlines and screen curvature.', - 'presets.styleCrtLabel': 'CRT', - 'presets.styleNvgTitle': - 'Simulate night-vision goggles with green intensification and a tube vignette.', - 'presets.styleNvgLabel': 'NVG', - 'presets.styleFlirTitle': - 'Simulate FLIR-style thermal contrast. Turn up Ironbow for color.', - 'presets.styleFlirLabel': 'FLIR', - 'presets.styleAnimeTitle': - 'Apply bright cel-shaded color and illustrated outlines.', - 'presets.styleAnimeLabel': 'Anime', - 'presets.styleNoirTitle': 'Apply high-contrast monochrome film-noir grading.', - 'presets.styleNoirLabel': 'Noir', - 'presets.styleSnowTitle': - 'Add a cold, snowy whiteout treatment to the scene.', - 'presets.styleSnowLabel': 'Snow', - // Key-only: the static span text is pinned verbatim by mapStackChips.test.mjs - // (id="map-source-label">MAP SOURCE<), so no attribute can be added without - // touching that test; phase 3 owns the swap. - 'presets.mapSourceLabel': 'MAP SOURCE', - // Key-only: the chip-row tag is pinned literally by mapStackChips.test.mjs. - 'presets.mapSourceChipsAriaLabel': 'Map source', - 'presets.miniStyleLabel': 'Style', - - // index.html #pp-toggles DISPLAY rail - // Key-only: .pp-header-label text is pinned verbatim by panelStackLayout.test.mjs. - 'display.title': 'DISPLAY', - 'display.collapseTitle': 'Collapse panel', - 'display.hudToggleTitle': 'Intelligence HUD (H)', - 'display.hudLayoutLabel': 'Layout', - 'display.hudLayoutAriaLabel': 'HUD layout', - 'display.hudLayoutTactical': 'Tactical', - 'display.hudLayoutOperator': 'Operator', - 'display.hudLayoutMinimal': 'Minimal', - 'display.detectionToggleTitle': 'Detection Overlay (D)', - 'display.detectionAriaLabel': 'Detection overlay', - 'display.detectionLabel': 'DETECT', - 'display.densityLabel': 'Density', - 'display.densityAriaLabel': 'Detection label density', - 'display.allocationLabel': 'Allocation', - 'display.allocationAriaLabel': 'Detection label allocation', - 'display.allocationElastic': 'Elastic', - 'display.allocationWeighted': 'Weighted', - 'display.fadeLabel': 'Fade', - 'display.fadeAriaLabel': 'Detection fade distance', - 'display.fadeTitle': - 'World-overlay fade distance outside the keyhole as a percentage of its radius', - 'display.outsideLabel': 'Outside', - 'display.outsideAriaLabel': 'Detection opacity outside the keyhole', - 'display.outsideTitle': - 'World-overlay label and card opacity beyond the fade distance', - 'display.parametersTitle': 'PARAMETERS', - 'display.parametersCollapseTitle': 'Collapse panel', - 'display.modelsToggleTitle': - '3D aircraft — flat icons zoomed out, 3D models up close', - 'display.modelsLabel': 'Models', - 'display.modelsCoverageAriaLabel': '3D model coverage', - 'display.modelsModeProximity': 'Proximity', - 'display.modelsModeAll': 'All', - 'display.scopeToggleTitle': 'Scope — the circular viewport mask', - 'display.scopeLabel': 'Scope', - 'display.featherLabel': 'Feather', - 'display.featherTitle': - 'Scope edge feather as a percentage of the keyhole radius', - 'display.celestialToggleTitle': 'Celestial ring — reveal the full globe', - 'display.celestialLabel': 'Celestial', - 'display.cleanViewToggleTitle': 'Hide UI chrome', - 'display.cleanViewLabel': 'Clean UI', - 'display.bloomToggleTitle': 'Bloom / Glow', - 'display.bloomLabel': 'Bloom', - 'display.sharpenToggleTitle': 'Sharpening', - 'display.sharpenLabel': 'Sharpen', - - // index.html location bar (dock locations tray; ui.js owns the runtime half) - // Key-only: .location-toolbar-label shares its element with dock-label-icon. - 'location.toolbarLabel': 'LOCATION', - 'location.collapseTitle': 'Collapse panel', - 'location.pinAriaLabel': 'Pin location tray', - 'location.pinTitle': 'Keep location tray open', - // Initial mini-status values; ui.js rewrites both once a place resolves. - 'location.miniCityInitial': '📍 Location: --', - 'location.miniPoiInitial': 'Landmark: --', - 'location.searchToggleTitle': 'Search any location', - 'location.searchPlaceholder': 'Search any location...', - - // index.html cockpit HUD statics - 'hud.level': 'LEVEL', - 'hud.routeDirectionAriaLabel': 'Estimated destination direction', - // Initial idle readout; ui.js rewrites it with a live bearing. - 'hud.routeDirectionIdle': 'DEST ---°', - 'hud.visorPlane': 'OPTICAL PLANE · 01', - 'hud.visorLock': 'VISOR LOCK · ACTIVE', - 'hud.firstPerson': 'FIRST PERSON', - // Initial meta line; ui.js rewrites it from live track state. - 'hud.aircraftMetaInitial': 'LIVE TRACK · COURSE ALIGNED', - 'hud.visionGroupAriaLabel': 'Cockpit vision style', - 'hud.compassAriaLabel': 'Current aircraft heading', - 'readout.rimGroundSpeed': 'GROUND SPEED · KTS', - 'readout.rimAltitude': 'ALTITUDE · FT', - - // index.html cockpit vision controls - 'vision.previousLabel': 'PREV', - 'vision.previousAriaLabel': 'Previous cockpit vision style', - 'vision.previousTitle': 'Previous vision style', - // The NORMAL token inside these two is the style name; phase 3 splits the - // dynamic token out when the runtime half is extracted. - 'vision.currentAriaLabel': - 'Current cockpit vision style: NORMAL. Activate for next style.', - 'vision.currentTitle': 'Current style: NORMAL — click for next', - 'vision.nextLabel': 'NEXT', - 'vision.nextAriaLabel': 'Next cockpit vision style', - 'vision.nextTitle': 'Next vision style', - - // index.html view switcher - 'exit.navAriaLabel': 'View switcher', - 'exit.resetAriaLabel': 'Reset cockpit to full globe view', - 'exit.resetTitle': 'Exit cockpit and return to full globe view', - 'exit.resetLabel': 'RESET', - 'exit.ariaLabel': 'Exit cockpit view', - 'exit.title': 'Exit cockpit view', - - // index.html cockpit route card - 'route.cardAriaLabel': 'Estimated flight plan', - 'route.kicker': 'ESTIMATED FLIGHT PLAN', - 'route.statusUnavailable': 'ROUTE DATA UNAVAILABLE', - 'route.fromLabel': 'FROM', - 'route.toLabel': 'TO', - 'route.unknownEndpoint': 'UNKNOWN', - - // index.html cockpit briefing carousel - // (brief.kicker 'LIVE SIGNALS' is seeded above but is KEY-ONLY: #cockpit-brief-kicker - // shares its element with the live-dot , so no data-i18n attribute can target it.) - 'brief.carouselAriaLabel': 'Cockpit briefing carousel', - 'brief.actionsAriaLabel': 'Cockpit briefing controls', - 'brief.previousAriaLabel': 'Previous briefing page', - 'brief.previousTitle': 'Previous briefing page', - 'brief.nextAriaLabel': 'Next briefing page', - 'brief.nextTitle': 'Next briefing page', - // Title is static prose; the visible label + aria-label are CYCLE ON/OFF - // states that belong to the phase-3 runtime extraction. - 'brief.autoTitle': - 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.', - 'brief.collapseAriaLabel': 'Collapse cockpit briefing panel', - 'brief.collapseTitle': 'Collapse briefing panel', - 'brief.signalsAriaLabel': 'Live signals', - 'brief.newsAriaLabel': 'Latest regional news', - 'brief.localAriaLabel': 'Location-based information', - 'brief.newsAcquiring': 'ACQUIRING REGIONAL NEWS', - 'brief.localResolving': 'RESOLVING REGION', - 'brief.labelTemp': 'TEMP', - 'brief.labelWind': 'WIND', - 'brief.labelSky': 'SKY', - 'brief.labelPrecip': 'PRECIP', - 'brief.sourceNote': 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', - 'brief.tabSignalsAriaLabel': 'Show Live Signals', - 'brief.tabNewsAriaLabel': 'Show Regional News', - 'brief.tabLocalAriaLabel': 'Show Local Info', - - // index.html cockpit context panel (right-rail Global Context included) - 'context.panelAriaLabel': 'Contact cockpit summary', - 'context.navAriaLabel': 'Contact navigation', - 'context.previousAriaLabel': - 'Previous — prior visited contact in the 250 km window', - 'context.previousTitle': - 'Previous — prior visited contact in the 250 km window', - 'context.nextAriaLabel': - 'Next — nearest unvisited contact in the 250 km window', - 'context.nextTitle': 'Next — nearest unvisited contact in the 250 km window', - 'context.toggleAriaLabel': 'Collapse Contact panel', - 'context.toggleTitle': 'Collapse contact panel', - 'context.qualifier': 'CONTEXT ONLY', - 'context.cohortsAriaLabel': 'Nearby cohort counts', - 'context.nearestLabel': 'NEAREST OBSERVED / MAPPED', - 'context.nearestEmpty': 'NO AVAILABLE EXAMPLE', - 'context.uncertaintyNote': 'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR', - 'context.weatherEnableAriaLabel': 'Enable cockpit weather effects', - 'context.weatherEnableTitle': 'Enable cockpit weather effects', - 'context.panelTitle': 'CONTEXT', - 'context.collapseTitle': 'Expand panel', - 'context.modesAriaLabel': 'Context mode', - 'context.contactsTabAriaLabel': 'CONTACTS', - 'context.contactsTabTitle': - 'Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.', - 'context.contactsTabLabel': 'CONTACTS', - 'context.missionsTabLabel': 'SPACE MISSIONS', - 'context.standbyTitle': 'SELECT CONTEXT', - 'context.actionsAriaLabel': 'Contact Context actions', - 'context.cockpitEntryLabel': 'COCKPIT', - 'context.searchNearbyLabel': 'SEARCH NEARBY SITES', - 'context.tr3bAriaLabel': 'Reclassify tracked contact as TR-3B', - 'context.tr3bTitle': 'Reclassify as TR-3B', - 'context.awarenessOffTitle': 'CONTACTS CONTEXT OFF', - 'context.awarenessOffHint': - 'SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY', - 'context.rosterAriaLabel': 'Available Space Missions', - 'context.rosterTitle': 'AVAILABLE MISSIONS', - 'context.rosterHint': 'SELECT A MISSION TO INSPECT', - 'context.rosterLoading': 'LOADING 30-DAY MISSION INDEX', - 'context.radioToggleAriaLabel': 'Open compact Radio controls', - 'context.radioToggleTitle': 'Open compact Radio controls', - 'context.radioMiniGroupAriaLabel': 'Compact Radio controls', - 'context.radioMiniTitle': 'RADIO', - 'context.radioMiniReady': 'RADIO READY', - 'context.radioDetailsAriaLabel': 'Open detailed Radio controls', - 'context.radioDetailsTitle': 'Open detailed Radio controls', - 'context.radioCloseAriaLabel': 'Close compact Radio controls', - 'context.radioCloseTitle': 'Close compact Radio controls', - 'context.radioMiniEnable': 'ENABLE', - 'context.radioMiniPrevAriaLabel': 'Previous filtered radio station', - 'context.radioMiniPrevTitle': 'Previous station', - 'context.radioMiniPlayAriaLabel': 'Play selected radio station', - 'context.radioMiniPlayTitle': 'Play', - 'context.radioMiniNextAriaLabel': 'Next filtered radio station', - 'context.radioMiniNextTitle': 'Next station', - 'context.radioMiniVolumeLabel': 'VOLUME', - 'context.radioMiniVolumeAriaLabel': 'Compact Radio volume', - - // index.html cockpit utility controls + compact radio popover - 'utility.controlsAriaLabel': 'Cockpit display and Radio controls', - 'utility.displayLabel': 'DISPLAY', - 'utility.radioLabel': 'RADIO', - 'utility.displayToggleAriaLabel': 'Expand Cockpit display options', - 'utility.displayToggleTitle': 'Expand Cockpit display options', - 'utility.displayPanelAriaLabel': 'Cockpit display options', - 'utility.radioToggleAriaLabel': 'Expand Cockpit Radio controls', - 'utility.radioToggleTitle': 'Expand Cockpit Radio controls', - 'utility.radioPanelAriaLabel': 'Cockpit compact Radio controls', - 'radio.enable': 'ENABLE', - 'radio.prevAriaLabel': 'Previous filtered radio station', - 'radio.playAriaLabel': 'Play selected radio station', - 'radio.nextAriaLabel': 'Next filtered radio station', - 'radio.volumeLabel': 'VOLUME', - 'radio.volumeAriaLabel': 'Cockpit Radio volume', - - // ── Phase-3 runtime extraction (src/ui.js), appended ───────────────────── - // Values stay byte-identical to the previous English literals; existing - // tests pin many of them under the default locale. - - // _syncPanelCollapseButton(): composed panel collapse titles. {name} is the - // panel's own (already localized) .panel-title text. - 'panel.expandTitle': 'Expand {name}', - 'panel.collapseTitle': 'Collapse {name}', - // Fallback when a panel ships no .panel-title/.pp-header-label node. - 'panel.fallbackName': 'panel', - // Radio panel collapse state; the expand state reuses the layers.radio.* - // keys seeded on the static button in phase 2. - 'panel.radioCollapseTitle': 'Collapse Radio', - 'panel.radioCollapseAria': 'Collapse Radio section', - // _updateLocationMiniStatus(): runtime rewrite of the collapsed LOCATION - // readout's single-segment search fallback (initial-state keys - // location.miniCityInitial/miniPoiInitial were seeded in phase 2). - 'location.miniSearchedPlaceholder': 'Searched location', - - // ── Phase-3 runtime extraction batch 2: cockpit + Context renderers ─────── - - // syncWeatherToggle(): state-sibling keys for the runtime ON/OFF rewrite - // (context.weatherEnableAriaLabel was seeded on the static button). - 'context.weatherDisableAriaLabel': 'Disable cockpit weather effects', - 'context.weatherStateOn': 'ON', - 'context.weatherStateOff': 'OFF', - // syncTr3bToggle(): converted-state title sibling of context.tr3bTitle. - 'context.tr3bRestoreTitle': 'Restore real aircraft', - // setVisionMode(): the style token is dynamic, so the sentence is split out - // of the seeded vision.currentAriaLabel/currentTitle (whose values pin the - // NORMAL default) into {style} templates. - 'vision.styleNameNightVision': 'Night vision', - 'vision.styleNameThermal': 'Thermal', - 'vision.styleNameAnime': 'Anime', - 'vision.styleNameNoir': 'Noir', - 'vision.styleNameSnow': 'Snow', - 'vision.currentAriaTemplate': - 'Current cockpit vision style: {style}. Activate for next style.', - 'vision.currentTitleTemplate': 'Current style: {style} — click for next', - - // Cockpit signal stream (pushCockpitSignal / renderCockpitSignals). - 'signal.trackAcquired': 'TRACK ACQUIRED', - 'signal.trackDetail': '{label} · COURSE {heading}°', - 'signal.selectFlightAria': 'Select flight {title}', - 'signal.contextStandby': 'CONTEXT STANDBY', - 'signal.contextStandbyHint': 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', - 'signal.contactLostTitle': 'CONTACT LOST · {subject}', - 'signal.contactLostDetail': - 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', - 'signal.classMilitary': 'MILITARY FLIGHT', - 'signal.classCommercial': 'COMMERCIAL FLIGHT', - 'signal.contactCurrent': '{aircraftClass} · CURRENT', - 'signal.contactRange': '{aircraftClass} · {distance}', - 'signal.distanceUnknown': 'DISTANCE UNKNOWN', - 'signal.inputsUnknown': { - one: '{count} INPUT UNKNOWN', - other: '{count} INPUTS UNKNOWN', - }, - 'signal.sourceStatusUnavailable': 'SOURCE STATUS UNAVAILABLE', - - // updateHud(): callsign fallback + the composed aircraft meta line (each - // feed state is its own key so no state can leak English later). - 'hud.fallbackCallsign': 'AIRCRAFT', - 'hud.metaClassMilitary': 'MILITARY', - 'hud.metaClassCommercial': 'COMMERCIAL', - 'hud.metaFeedAcquiringSurface': 'ACQUIRING SURFACE', - 'hud.metaFeedSurfaceFallback': 'SURFACE FALLBACK', - 'hud.metaFeedStale': 'STALE FEED', - 'hud.metaFeedLive': 'LIVE TRACK', - 'hud.aircraftMetaTemplate': '{aircraftClass} · {feedState} · COURSE ALIGNED', - - // updateRoute(): runtime state siblings of the seeded route keys. - 'route.statusArrowEstimated': 'ARROW · ESTIMATED DIRECTION', - 'route.directionLabel': 'DEST {bearing}', - - // updateContext(): uncertainty/nearest/bearing readout states. - 'context.uncertaintyContactLost': - 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', - 'context.uncertaintyInputsUnknown': { - one: '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', - other: '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', - }, - 'context.uncertaintyInputsCurrent': - 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR', - 'context.nearestTemplate': '{cohort} · {contact}', - 'context.nearestUnavailableAria': '{cohort}, Unavailable', - 'context.bearingNone': 'BRG —', - 'context.bearingAhead': 'AHEAD', - 'context.bearingSide': '{side} {angle}', - 'context.sideLeft': 'L', - 'context.sideRight': 'R', - - // setContextCollapsed()/setSignalCollapsed(): expand-state siblings. - 'context.toggleExpandAriaLabel': 'Expand Contact panel', - 'context.toggleExpandTitle': 'Expand contact panel', - 'brief.expandAriaLabel': 'Expand cockpit briefing panel', - 'brief.expandTitle': 'Expand briefing panel', - - // setBriefAutoRotate(): cycle toggle states + the ON-state help text (the - // OFF-state help is the seeded brief.autoTitle). - 'brief.autoOn': 'CYCLE ON', - 'brief.autoOff': 'CYCLE OFF', - 'brief.autoTitleOn': - 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.', - - // Briefing carousel pages (kicker/subtitle/source per page; the news/local - // source lines are provider attribution and stay English). - 'brief.kickerNews': 'REGIONAL NEWS', - 'brief.kickerLocal': 'LOCAL INFO', - 'brief.subtitleNews': 'LATEST LOCATION-MATCHED REPORTING', - 'brief.subtitleLocal': 'PLACE / CONDITIONS / POSITION', - - // Local position + regional brief status states. - 'brief.positionUnavailable': 'POSITION UNAVAILABLE', - 'brief.newsUnavailable': 'REGIONAL NEWS UNAVAILABLE', - 'brief.regionUnavailable': 'REGION UNAVAILABLE', - 'brief.newsEmpty': 'NO RECENT LOCATION MATCHES', - - // renderRegionalBrief(): article metadata, cloud readout, and age chips. - 'brief.metadataSourceFallback': 'SOURCE', - 'brief.articleMetaTemplate': '{domain} · {age}', - 'brief.newsSourceLine': '{source} · LOCATION QUERY', - 'brief.age.timeUnknown': 'TIME UNKNOWN', - 'brief.age.minutes': '{count}M AGO', - 'brief.age.hours': '{count}H AGO', - 'brief.age.days': '{count}D AGO', - 'brief.wind.dirUnknown': 'DIR UNKNOWN', - 'brief.cloudTemplate': 'CLOUD {pct}%', - 'brief.cloudUnknown': 'CLOUD UNKNOWN', - - // ── Phase-3 runtime extraction batch 3: toasts, loading helpers, map tray ─ - - // Panel-chrome + share/location toasts. - 'panel.layoutResetToast': - 'Panel layout updated — positions reset to new defaults', - 'share.toastCopied': 'Link copied!', - 'share.toastCopyFailed': 'Copy failed', - 'location.toastNotFound': 'Location not found', - 'location.toastSearchFailed': 'Search failed', - 'location.toastFlyToPoiFirst': 'Fly to a POI first', - 'actions.clearLayersBusyAria': 'Clearing selected data layers', - 'actions.clearLayersFailedToast': 'Selected data layers could not be cleared', - - // Global status chip notices (_handleShareTrackingRestoreStatus). - 'status.acquiring': 'ACQUIRING', - 'status.subjectFallback': 'entity', - 'status.sharedSubjectDetail': 'SHARED {subject}', - 'status.sharedFollowExpired': 'Shared {subject} follow expired', - 'status.sharedRestoreFailed': - 'Shared {subject} could not be restored — feed unavailable', - 'status.sharedUnavailable': 'Shared {subject} is unavailable', - - // Context mode user-facing action failures. - 'context.modeContext': 'Context', - 'context.modeSpaceMissions': 'Space Missions', - 'context.toastStartBlocked': - '{mode} could not start because another layer did not stop cleanly', - 'context.toastTransitionFailedContacts': - 'Contacts could not complete the requested transition; try again', - 'context.toastTransitionFailedMissions': - 'Space Missions could not complete the requested transition; try again', - 'context.toastInstallationsRefreshFailed': - 'Nearby installations could not be refreshed; try again', - 'context.toastRestoreFailed': - 'Context could not restore every layer; try again', - 'context.toastZoomToSearch': 'Zoom in to search mapped installations', - 'context.toastInstallationsRefreshed': 'Nearby installations refreshed', - 'context.toastLayerUnavailable': - 'That layer is unavailable in the current Context mode', - 'context.actionStart': 'start', - 'context.actionStop': 'stop', - 'context.toastLayerLifecycleFailed': '{layerId} could not {action} cleanly', - 'radio.toastLifecycleFailed': 'Radio could not {action} cleanly', - - // CCTV sync chip captions + calibration/availability toasts. - 'cctv.syncLoadingFrames': 'loading frames', - 'cctv.syncGridReady': 'camera grid ready', - 'cctv.toastCalibrationSaved': 'CCTV calibration saved', - 'cctv.toastCalibrationReset': 'CCTV calibration reset', - 'cctv.toastLayerUnavailable': 'CCTV layer unavailable', - - // _renderMapStackState(): status chip fallback when no stack label resolves - // (stack names themselves are keep-English provider/stack ids). - 'presets.mapStackFallback': 'MAP', - - // ── Phase-3 runtime extraction batch 4: awareness / CCTV / Radio panels ─── - // (radio.* and cctv.* state values that layers.js already seeds are reused - // cross-namespace; these are the runtime-only siblings and compositions.) - - // _applyRuntimeStaticHeaderText(): the key-only standby description span - // (both mode descriptions in one span split by a literal
in phase 2). - 'context.standbyContactsDesc': 'CONTACTS — nearest planes · vessels · sites', - 'context.standbyMissionsDesc': 'SPACE MISSIONS — launches & orbital assets', - // _syncContextRadioLauncherState(): close-state sibling of the seeded - // context.radioToggleAriaLabel ('Open compact Radio controls'). - 'context.radioToggleCloseAriaLabel': 'Close compact Radio controls', - 'context.toastMissionsCancelRestoreFailed': - 'Space Missions cancellation could not restore the previous layer state', - // Cockpit utility disclosures: collapse-state siblings of the seeded - // utility.*ToggleAriaLabel expand keys. - 'utility.displayToggleCollapseAriaLabel': 'Collapse Cockpit display options', - 'utility.radioToggleCollapseAriaLabel': 'Collapse Cockpit Radio controls', - - // Radio panel runtime states (state-sibling keys so no state can leak - // English later; lifecycle enum labels reuse layers.status.*). - 'radio.stateSync': 'SYNC', - 'radio.tunerCategoryBand': '{category} BAND', - 'radio.tunerNoStations': 'NO STATIONS', - 'radio.tunerStationAria': '{name}, station {index} of {total}', - 'radio.tunerNoStationAria': 'No station available', - 'radio.tunerOffAir': 'OFF AIR', - 'radio.tunerStationUnavailable': 'STATION UNAVAILABLE', - 'radio.actionPlay': 'Play', - 'radio.actionPause': 'Pause', - 'radio.actionResume': 'Resume', - 'radio.targetSelected': 'selected', - 'radio.targetNearest': 'nearest', - 'radio.playStateAria': '{action} {target} radio station', - 'radio.miniStateUncertain': 'RADIO STATE UNCERTAIN', - 'radio.miniSyncingDirectory': 'SYNCING DIRECTORY', - 'radio.stationSyncing': 'SYNCING', - 'radio.stationFallback': 'station', - 'radio.playbackReadyFallback': 'Ready', - - // CCTV panel runtime states (layers.cctv.* seeded keys reused where they - // match; these are the runtime-only compositions and flipped states). - 'cctv.coverageViewshedOn': 'VIEWSHED ON', - 'cctv.frameLoading': 'FRAME · LOADING', - 'cctv.frameUnavailable': 'FRAME · UNAVAILABLE', - 'cctv.calChipEdited': 'CAL · EDITED (UNSAVED)', - 'cctv.calChipTemplate': 'CAL · {badge}', - 'cctv.calBadgeCalibrated': 'CALIBRATED', - 'cctv.calBadgeCurated': 'CURATED', - 'cctv.calBadgeRawPrior': 'RAW PRIOR', - 'cctv.metaProjectionMonitor': 'MONITOR', - 'cctv.metaProjectionOff': 'OFF', - 'cctv.metaTemplate': - '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', - 'cctv.metaCamerasClick': { - one: '{count} camera loaded · click a camera to activate', - other: '{count} cameras loaded · click a camera to activate', - }, - 'cctv.metaCamerasEnable': { - one: '{count} camera loaded · enable CCTV to activate', - other: '{count} cameras loaded · enable CCTV to activate', - }, - 'cctv.summaryNoneAvailable': 'No summary available.', - - // ── Phase-3 runtime extraction batch 5: remainder + hud.js ──────────────── - - // _setCelestialRingEnabled(): unsupported-style title sibling of the seeded - // display.celestialToggleTitle. - 'display.celestialUnavailableTitle': - 'Celestial ring — available in Normal style', - // _updateDetectionButton(): density-profile labels (display.detectionLabel - // 'DETECT' was seeded on the static button) + composed aria. - 'display.detectionAriaTemplate': 'Detection overlay: {mode}', - 'display.detectionAriaOff': 'Detection overlay: off', - 'display.detectionLabelSparse': 'SPARSE', - 'display.detectionLabelBalanced': 'BALANCED', - 'display.detectionLabelDense': 'DENSE', - // Globe-reset buttons: idle/working aria states (cockpit variant sibling). - 'hud.resetGlobeAria': 'Reset to full globe view', - 'hud.resetGlobeCockpitAria': 'Reset cockpit to full globe view', - 'hud.resettingGlobeAria': 'Resetting to full globe view', - 'hud.resettingGlobeCockpitAria': 'Resetting cockpit to full globe view', - // _initOrbit(): indicator caption after the orbit glyph. - 'location.orbitLabel': 'ORBIT', - // Intel HUD (src/hud.js): summary caption, idle placeholder, REC indicator. - // Classification banners, terse instrument readout codes (MGRS/GSD/NIIRS/ - // ALT/COLL/ONA/BAND/BITS/LVL), and the AI summary line stay keep-English. - 'hud.summaryLabel': 'SUMMARY', - 'hud.summaryAwaiting': 'Awaiting telemetry...', - 'hud.recLabel': 'REC', - - // Upstream a11y additions (post-merge follow-up): slider/toggle accessible names. - 'display.featherAria': 'Scope edge feather', - 'display.bloomAria': 'Bloom intensity', - 'display.sharpenAria': 'Sharpen intensity', - 'location.searchAria': 'Search location by name or coordinates', - 'location.toggleAria': 'Expand LOCATION', -}; diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js deleted file mode 100644 index 045396298..000000000 --- a/src/i18n/locales/en/layers.js +++ /dev/null @@ -1,517 +0,0 @@ -// English catalog — layers namespace (phase-1 seed). -// -// Owns data-layer presentation copy: display names, feed-state labels, and -// layer batch toasts. Machine-readable values (layer ids, status enum keys, -// provider names) are intentionally NOT here — see docs/TRANSLATORS.md -// for the keep-English boundary. Keys are namespace-relative; the registry -// prefixes them with `layers.`. -export const NAMESPACE = 'layers'; - -export default { - // src/data/manager.js FEED_STATE_LABELS value - 'status.unavailable': 'UNAVAILABLE', - // src/data/flights.js / src/data/earthquakes.js layer display names - 'name.liveFlights': 'Live Flights', - 'name.earthquakes': 'Earthquakes (24h)', - // src/ui.js clearSelectedLayers() result toasts (genuine plural copy) - 'clear.toast.noneSelected': 'No selected data layers', - 'clear.toast.cleared': { - one: 'Cleared {count} data layer', - other: 'Cleared {count} data layers', - }, - 'clear.toast.notCleared': { - one: '{count} data layer could not be cleared', - other: '{count} data layers could not be cleared', - }, - - // ── Phase-2 static markup extraction (index.html), appended ────────────── - // The shell worker owns ALL index.html static markup in phase 2 - // (docs/TRANSLATORS.md cross-surface rule); these keys cover the - // layers-worker surfaces (world overlay, CCTV panel, Radio panel) whose - // runtime presentation paths land here in phase 3. - - // index.html #world-overlay-actions region (src/overlays/worldOverlay.js) - 'overlay.regionAriaLabel': 'Visible map targets', - - // index.html CCTV panel chrome. Button texts marked as states (toggleOff, - // coverageOff, autoHopOff, projectionOn) are the shipped initial values; the - // phase-3 layer worker adds the flipped-state siblings. - 'cctv.panelTitle': 'CCTV', - 'cctv.collapseTitle': 'Collapse panel', - 'cctv.sourceUnknown': 'SOURCE · UNKNOWN', - 'cctv.metaIdle': 'Enable CCTV to load camera intersections', - 'cctv.toggleOff': 'CCTV OFF', - 'cctv.nearest': 'NEAREST', - 'cctv.prev': 'PREV', - 'cctv.cameraAriaLabel': 'CCTV camera', - 'cctv.next': 'NEXT', - 'cctv.focus': 'FOCUS', - 'cctv.coverageOff': 'COVERAGE OFF', - 'cctv.autoHopOff': 'AUTO HOP OFF', - 'cctv.projectionOn': 'PROJECTION ON', - 'cctv.calibrationLabel': 'CALIBRATION', - 'cctv.adjustLabel': 'ADJUST', - 'cctv.adjustTitle': - 'Drag the camera in the world: rings rotate, arrows move, handles set range/FOV', - 'cctv.calPoseAriaLabel': 'Camera pose — click a value to type', - 'cctv.calHeadingTitle': 'Heading (compass °) — click to type', - 'cctv.calPitchTitle': 'Pitch (° up/down) — click to type', - 'cctv.calFovTitle': 'Horizontal FOV (°) — click to type', - 'cctv.calRangeTitle': 'Range / monitor-plane distance (m) — click to type', - 'cctv.calHeightTitle': 'Mount height above ground (m) — click to type', - 'cctv.calNorthTitle': - 'North offset from catalog position (m) — click to type', - 'cctv.calEastTitle': 'East offset from catalog position (m) — click to type', - 'cctv.saveCal': 'SAVE CAL', - 'cctv.resetCal': 'RESET CAL', - 'cctv.summaryLabel': 'SCENE SUMMARY', - 'cctv.summaryIdle': - 'Enable CCTV to start camera-linked intelligence summaries.', - - // index.html Radio panel chrome (right rail #radio-panel) - 'radio.panelAriaLabel': 'Internet radio and local SDR', - 'radio.panelTitle': 'RADIO', - 'radio.expandTitle': 'Expand Radio', - 'radio.expandAriaLabel': 'Expand Radio section', - 'radio.enable': 'ENABLE', - 'radio.stationTagLabel': 'STATION TAG', - 'radio.filterAriaLabel': 'Filter stations by station tag', - 'radio.filterAll': 'All', - 'radio.noStation': 'NO STATION SELECTED', - 'radio.stationHint': 'Enable Radio, then choose a globe marker or use next.', - // Key-only: the #radio-tuner-band-label span text is pinned verbatim by - // radioMarkup.test.mjs, so the attribute must be wired by a later phase. - 'radio.bandLabel': 'DIRECTORY BAND', - 'radio.dragToTune': 'DRAG TO TUNE', - 'radio.tunerIdle': 'ALL · DRAG THE NEEDLE', - 'radio.snapsNote': 'SNAPS TO AVAILABLE STATIONS', - 'radio.transportAriaLabel': 'Radio playback', - 'radio.prev': 'PREV', - 'radio.prevAriaLabel': 'Previous filtered station', - 'radio.play': 'PLAY', - 'radio.playAriaLabel': 'Play selected station', - 'radio.next': 'NEXT', - 'radio.nextAriaLabel': 'Next filtered station', - 'radio.stop': 'STOP', - 'radio.stopAriaLabel': 'Stop radio playback', - 'radio.volumeLabel': 'VOLUME', - 'radio.volumeAriaLabel': 'Radio volume', - // Initial idle announcement; the runtime states land with the layers worker. - 'radio.playbackOff': 'Radio off', - 'radio.stationSite': 'STATION SITE', - 'radio.privacyNote': - 'Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.', - - // ── Phase-3 runtime extraction (src/data/*), appended ──────────────────── - // Layer display names are translated only at the presentation boundary - // (manager.js _renderToggles/_syncToggleButton map layer.id → key here). - // layer.id values, FEED_STATE_LABELS enum KEYS, and lifecycle state keys - // stay English — see docs/TRANSLATORS.md keep-English boundary. - - // src/data/manager.js FEED_STATE_LABELS values + toggle-button states. - 'status.on': 'ON', - 'status.loading': 'LOADING', - 'status.degraded': 'DEGRADED', - 'status.stale': 'STALE', - 'status.fallback': 'FALLBACK', - 'status.off': 'OFF', - 'status.partial': 'PARTIAL', - 'status.uncertain': 'UNCERTAIN', - 'status.enabling': 'ENABLING', - 'status.disabling': 'DISABLING', - - // Layer display names (toggle panel rows + toggle-button aria-label). - 'name.militaryFlights': 'Military Flights', - 'name.satellites': 'Satellites', - 'name.rocketLaunches': 'Space Missions (30d)', - 'name.traffic': 'Street Traffic', - 'name.cctv': 'CCTV', - 'name.radio': 'Radio', - 'name.bikeshare': 'Bikeshare', - 'name.aisVessels': 'Live AIS Vessels', - 'name.installations': 'Mapped Installations', - 'name.globalContext': 'Global Context', - 'name.datacenters': 'Datacenters', - 'name.dams': 'Dams', - 'name.submarineCables': 'Submarine Cables', - 'name.fires': 'FIRMS Active Fires', - - // src/data/manager.js data-panel row meta line (_buildMetaText) + time ago. - 'meta.justNow': 'just now', - 'meta.secondsAgo': '{count}s ago', - 'meta.minutesAgo': '{count}m ago', - 'meta.hoursAgo': '{count}h ago', - 'meta.never': 'never', - 'meta.dataSourceUnavailable': 'Data source unavailable', - 'meta.loading': 'loading...', - 'meta.uncertainLifecycle': - 'UNCERTAIN · {source} · lifecycle state requires reconciliation', - 'meta.transitioning': '{state} · {source}', - 'meta.stateSourceRetry': '{state} · {source} · {detail} · retry {seconds}s', - 'meta.stateSourceDetail': '{state} · {source} · {detail}', - 'meta.stateSourceAgo': '{state} · {source} · {ago}', - 'meta.stateSourceAgoRetry': - '{state} · {source} · {ago} · retrying in {seconds}s', - 'meta.sourceLoading': '{source} · {loadingLabel}', - 'meta.sourceAgo': '{source} · {ago}', - 'meta.partialCounts': '{accepted} of {raw} records accepted', - 'meta.partialIncomplete': 'incomplete snapshot', - 'meta.partialDetail': '{state} · {source} · {detail} · {ago}', - 'meta.toggleAriaLabel': '{name}: {state}', - - // src/data/militaryFlights.js tracked readout fallback captions. - 'readout.typeUnknown': 'Type unknown', - 'readout.regUnknown': 'Reg unknown', - 'readout.operatorUnknown': 'Operator unknown', - 'readout.altUnknown': 'Alt unknown', - - // src/data/vesselLabels.js AIS type names (display only — the styling - // matcher keeps the English tokens as machine values). - 'vessel.type.fishing': 'FISHING', - 'vessel.type.towing': 'TOWING', - 'vessel.type.dredger': 'DREDGER', - 'vessel.type.diveOps': 'DIVE OPS', - 'vessel.type.military': 'MILITARY', - 'vessel.type.sailing': 'SAILING', - 'vessel.type.pleasure': 'PLEASURE', - 'vessel.type.pilot': 'PILOT', - 'vessel.type.sar': 'SAR', - 'vessel.type.tug': 'TUG', - 'vessel.type.portTender': 'PORT TENDER', - 'vessel.type.antiPollution': 'ANTI-POLLUTION', - 'vessel.type.lawEnforce': 'LAW ENFORCE', - 'vessel.type.medical': 'MEDICAL', - 'vessel.type.highSpeed': 'HIGH-SPEED', - 'vessel.type.passenger': 'PASSENGER', - 'vessel.type.cargo': 'CARGO', - 'vessel.type.tanker': 'TANKER', - 'vessel.type.other': 'OTHER', - - // src/data/aisLiveVessels.js vessel cards + selected-vessel HUD. - 'vessel.fallback': 'VESSEL', - 'vessel.posLive': 'POS: LIVE', - 'vessel.posAt': 'POS: {time}Z', - 'vessel.hudIdle': 'AIS: --', - 'vessel.hudName': 'AIS: {name}', - 'vessel.hudTypeLine': '{type} SPD: {speed} HDG: {heading}', - 'vessel.awaitingFirstPosition': 'awaiting first AIS position…', - - // index.html CCTV panel flipped-state siblings (runtime writers live in - // src/ui.js, owned by core-ui — these keys are the layers-surface half of - // the cross-surface rule in docs/TRANSLATORS.md). - 'cctv.toggleOn': 'CCTV ON', - 'cctv.coverageOn': 'COVERAGE ON', - 'cctv.autoHopOn': 'AUTO HOP ON', - 'cctv.projectionOff': 'PROJECTION OFF', - 'cctv.adjustOn': 'ADJUST ON', - - // src/data/cctv.js buildSummaryText() — the SCENE SUMMARY line. - 'cctv.summary.standingBy': - '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE', - 'cctv.summary.empty': 'No cameras available in catalog.', - 'cctv.summary.city': '{city} CCTV', - 'cctv.summary.hdg': 'HDG {value}°', - 'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)', - 'cctv.summary.fov': 'FOV {value}°', - 'cctv.summary.coverage': 'COVERAGE {value}km²', - 'cctv.summary.overlap': 'OVERLAP {count} cams', - 'cctv.summary.isolated': 'ISOLATED VIEW', - 'cctv.summary.projMonitor': 'PROJ MONITOR', - 'cctv.summary.projOff': 'PROJ OFF', - 'cctv.summary.viewshed': 'VIEWSHED', - 'cctv.summary.cal': 'CAL {value}', - 'cctv.summary.src': 'SRC {value}', - 'cctv.summary.context': '{value} CONTEXT', - - // src/data/radio.js station-tag categories, genre labels, cluster badges. - 'radio.category.news': 'News', - 'radio.category.talk': 'Talk', - 'radio.category.weather': 'Weather / Emergency', - 'radio.category.publicSafety': 'Public Safety', - 'radio.category.aviationMarine': 'Aviation / Marine', - 'radio.category.trafficTransit': 'Traffic / Transit', - 'radio.category.music': 'Music', - 'radio.category.other': 'Other', - 'radio.genre.alternative': 'Alternative', - 'radio.genre.ambient': 'Ambient', - 'radio.genre.blues': 'Blues', - 'radio.genre.classical': 'Classical', - 'radio.genre.country': 'Country', - 'radio.genre.dance': 'Dance', - 'radio.genre.electronic': 'Electronic', - 'radio.genre.folk': 'Folk', - 'radio.genre.funk': 'Funk', - 'radio.genre.hipHop': 'Hip-Hop', - 'radio.genre.house': 'House', - 'radio.genre.indie': 'Indie', - 'radio.genre.jazz': 'Jazz', - 'radio.genre.latin': 'Latin', - 'radio.genre.metal': 'Metal', - 'radio.genre.oldies': 'Oldies', - 'radio.genre.pop': 'Pop', - 'radio.genre.punk': 'Punk', - 'radio.genre.rAndB': 'R&B', - 'radio.genre.reggae': 'Reggae', - 'radio.genre.rock': 'Rock', - 'radio.genre.soul': 'Soul', - 'radio.genre.techno': 'Techno', - 'radio.genre.trance': 'Trance', - 'radio.genre.world': 'World', - 'radio.cluster.news': 'NEWS', - 'radio.cluster.talk': 'TALK', - 'radio.cluster.weather': 'WEATHER', - 'radio.cluster.publicSafety': 'SAFETY', - 'radio.cluster.aviationMarine': 'AIR / SEA', - 'radio.cluster.trafficTransit': 'TRANSIT', - 'radio.cluster.music': 'MUSIC', - 'radio.cluster.other': 'OTHER', - - // Radio right-rail panel runtime states (seeded here per the ownership - // contract; src/ui.js wires the writes — core-ui). English values mirror - // the literals ui.js writes today so wiring is byte-identical under 'en'. - 'radio.noStationAvailable': 'NO STATION AVAILABLE', - 'radio.disable': 'DISABLE', - 'radio.reconcile': 'RECONCILE', - 'radio.enableAria': 'Enable Radio', - 'radio.disableAria': 'Disable Radio', - 'radio.reconcileAria': 'Reconcile Radio — lifecycle uncertain', - 'radio.meta.loading': 'Loading station directory…', - 'radio.meta.metadataOnly': 'Directory metadata only', - 'radio.meta.chooseHint': 'Choose a globe marker or use next.', - 'radio.tags': 'TAGS · {tags}', - 'radio.state.ready': 'Ready — playback starts only from your action', - 'radio.state.loading': 'Connecting directly to broadcaster…', - 'radio.state.buffering': 'Buffering broadcaster stream…', - 'radio.state.playing': 'Playing {station}', - 'radio.state.paused': 'Paused {station}', - 'radio.state.error': 'Broadcaster stream unavailable', - 'radio.state.degradedDirectory': ' · degraded directory', - 'radio.state.staleDirectory': ' · stale directory', - 'radio.state.staleDegradedDirectory': ' · stale/degraded directory', - 'radio.state.outsideFilter': ' · outside current filter', - 'radio.state.voiceMuted': ' · muted during voice interaction', - 'radio.state.voiceRestoring': ' · restoring volume after voice', - 'radio.state.staticNoAudio': ' · static indicates no broadcaster audio', - 'radio.state.tuningStatic': ' · tuning static until broadcaster starts', - 'radio.state.stationUnavailable': - 'Station unavailable after directory refresh — choose another channel', - 'radio.state.enabling': 'Radio is enabling…', - 'radio.state.disabling': 'Radio is disabling…', - 'radio.state.uncertain': - 'Radio lifecycle is uncertain — use Enable or Disable to reconcile', - - // src/data/rocketLaunches.js space-mission surface (runtime-built panel, - // roster, replay overlay, and map cards). - 'missions.launchSite': 'LAUNCH SITE', - 'missions.launchSiteValue': 'LAUNCH SITE · {site}', - 'missions.panel.selectedHeader': 'SELECTED SPACE MISSION', - 'missions.panel.showAllTitle': 'Show all missions', - 'missions.panel.deselectAria': 'Deselect mission', - 'missions.panel.mission': 'MISSION', - 'missions.panel.status': 'STATUS · ', - 'missions.panel.launchSite': 'LAUNCH SITE · ', - 'missions.panel.launchTime': 'LAUNCH TIME · ', - 'missions.panel.orbit': 'ORBIT · ', - 'missions.panel.ascentPath': 'ASCENT PATH · ', - 'missions.panel.distance': 'CURRENT DISTANCE FROM EARTH · ', - 'missions.panel.speed': 'SATELLITE SPEED · ', - 'missions.panel.payload': 'PAYLOAD', - 'missions.panel.colName': 'NAME', - 'missions.panel.colType': 'TYPE', - 'missions.panel.colDestination': 'DESTINATION', - 'missions.panel.stagesSection': 'STAGE / RE-ENTRY / RECOVERY', - 'missions.panel.colStage': 'STAGE', - 'missions.panel.colStatus': 'STATUS', - 'missions.panel.colFinalPosition': 'FINAL POSITION', - 'missions.panel.focus': 'FOCUS', - 'missions.panel.prev': 'PREV', - 'missions.panel.next': 'NEXT', - 'missions.panel.prevTitle': 'Previous mission', - 'missions.panel.nextTitle': 'Next mission', - 'missions.panel.showAll': 'SHOW ALL / DESELECT', - 'missions.roster.empty': 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', - 'missions.roster.count': '{count} / 30D', - 'missions.roster.dateUnavailable': 'DATE UNAVAILABLE', - 'missions.roster.unspecifiedOperator': 'UNSPECIFIED OPERATOR', - 'missions.roster.selectAria': 'Select {mission}', - 'missions.rows.additionalPayloads': '+{count} additional payload records', - 'missions.rows.classified': 'PAYLOAD DATA UNAVAILABLE', - 'missions.rows.noStageData': 'NO STAGE RE-ENTRY / RECOVERY DATA', - 'missions.rows.unspecified': 'UNSPECIFIED', - 'missions.rows.unavailable': 'UNAVAILABLE', - 'missions.rows.flightNumber': 'FLIGHT {count}', - 'missions.rows.reused': 'REUSED', - 'missions.rows.positionUnavailable': 'POSITION UNAVAILABLE', - 'missions.rows.kmDownrange': '{value} KM DOWNRANGE', - 'missions.rows.plannedPrefix': 'PLANNED · ', - 'missions.rows.suppliedTrajectory': 'SUPPLIED TRAJECTORY POINTS', - 'missions.rows.reconstructedEstimate': 'RECONSTRUCTED ESTIMATE', - 'missions.telemetry.km': '{value} KM', - 'missions.telemetry.kmPerSecond': '{value} KM/S', - 'missions.replay.start': 'REPLAY ASCENT', - 'missions.replay.startTitle': - 'Replay the estimated ascent with a following camera', - 'missions.replay.pause': 'Pause replay', - 'missions.replay.resume': 'Resume replay', - 'missions.replay.cancel': 'Cancel replay', - 'missions.replay.speedLabel': 'REPLAY SPEED', - 'missions.replay.speedAria': 'Replay speed multiplier', - 'missions.replay.countdown': 'T−{time} · {mission}', - 'missions.replay.standbyDetail': 'LAUNCH STANDBY', - 'missions.replay.liftoff': 'LIFTOFF · {mission}', - 'missions.replay.ascentReplay': 'ASCENT REPLAY · {mission}', - 'missions.replay.ascentEstimate': 'ASCENT ESTIMATE · {mission}', - 'missions.replay.recovery': 'STAGE RE-ENTRY / RECOVERY · {mission}', - 'missions.replay.orbit': 'ORBIT REPLAY · {mission}', - 'missions.replay.pausedPrefix': 'PAUSED · {title}', - 'missions.replay.phaseCountdown': 'T minus {seconds}', - 'missions.replay.phasePreparing': 'Preparing launch site', - 'missions.replay.phaseLiftoff': 'Liftoff', - 'missions.replay.phaseAscent': 'Ascent replay', - 'missions.replay.phaseOrbit': 'Orbit replay', - 'missions.replay.pausedSuffix': ', paused', - - // src/data/satellites.js dense-shell chip + src/data/satelliteClass.js - // class labels and legend blurbs. - 'satellites.denseChip': 'DENSE', - 'satellites.denseChipLoading': 'DENSE ···', - 'satellites.denseChipFailed': 'DENSE ✕', - 'satellites.denseTitleAdd': - 'Add the full Starlink broadband shell (thousands of extra points)', - 'satellites.denseTitleLoading': 'Loading the Starlink shell…', - 'satellites.denseTitleFailed': 'Starlink {detail} — click to retry', - 'satellites.loadFailed': 'load failed', - 'satellites.denseTitleActive': - 'Showing the full Starlink shell — click for the core catalog only', - 'satellites.class.station': 'STATION', - 'satellites.class.stationBlurb': - 'Crewed stations and their visiting vehicles', - 'satellites.class.nav': 'NAV', - 'satellites.class.navBlurb': 'GNSS navigation — GPS, GLONASS, Galileo', - 'satellites.class.geo': 'GEO', - 'satellites.class.geoBlurb': - 'Geostationary belt — comms and weather, fixed over the equator', - 'satellites.class.visual': 'VISUAL', - 'satellites.class.visualBlurb': - 'Brightest naked-eye objects — CelesTrak visual group', - 'satellites.class.comms': 'COMMS', - 'satellites.class.commsBlurb': - 'Broadband constellation shell — shown only in DENSE mode', - - // src/data/firmsHeatmap.js stats labels + fire/cell cards. - 'meta.refreshing': 'refreshing...', - 'firms.keyRequired': 'KEY REQUIRED', - 'firms.staleCached': 'STALE · cached {age}', - 'firms.liveUpdated': 'LIVE · updated {age}', - 'firms.underMinuteAgo': '<1m ago', - 'firms.card.fire': 'FIRE · {frp} MW', - 'firms.card.ambient': '▲ {frp} MW', - 'firms.card.fireNoun': 'FIRE', - 'firms.card.firesNoun': 'FIRES', - 'firms.card.confSuffix': '{value} conf', - 'firms.card.ageSuffix': '{value} ago', - 'firms.card.maxFrp': 'max {value} MW', - 'firms.card.newAge': 'new {value}', - 'firms.card.night': 'NIGHT', - 'firms.card.sensorUnavailable': 'sensor n/a', - 'firms.card.focusAria': 'Focus fire detection {title}, {details}', - - // src/data/traffic.js feed presentation (manager meta loadingLabel values). - 'traffic.loadingSyncing': - 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated', - 'traffic.liveCoverage': - 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov', - 'traffic.liveCoverageUnmatched': - 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated', - 'traffic.simulatedNoMatches': - 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)', - 'traffic.syncingRoads': 'Syncing flow · Roads: {source}', - 'traffic.liveRoadsFlowHidden': - 'LIVE · Roads: {source} · Roads without flow hidden', - 'traffic.liveRoadsNoFlowInView': - 'LIVE · Roads: {source} · No flow roads in view', - 'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%', - 'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%', - 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', - 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', - - // src/data/bikeshare.js loading labels + selected-station card. - 'bike.loadingSyncing': 'syncing {count} city feeds...', - 'bike.loadingScanning': 'scanning nearby systems...', - 'bike.card.capacity': '🚲 {bikes} avail · {docks} docks · {capacity} cap', - 'bike.warning.notInstalled': '⚠️ Not installed', - 'bike.warning.notRenting': '⚠️ Not renting', - 'bike.warning.notReturning': '⚠️ Not returning', - 'bike.stationFallback': 'Station', - 'bike.stationWithId': 'Station {id}', - - // src/data/militaryInstallations.js + installationFeedback.js. - 'installations.fallbackTitle': 'MAPPED INSTALLATION', - 'installations.loading': 'loading mapped installation context', - 'installations.feedback.reason.rateLimited': 'Overpass rate-limited', - 'installations.feedback.reason.timeout': 'Overpass timed out', - 'installations.feedback.reason.queryFailed': - 'Overpass could not complete the query', - 'installations.feedback.reason.unavailable': - 'Overpass temporarily unavailable', - 'installations.feedback.retrying': 'Retrying mapped sites…', - 'installations.feedback.fetching': 'Fetching mapped sites…', - 'installations.feedback.retryIn': '{reason} — retrying in {seconds}s', - 'installations.feedback.retryPending': '{reason} — retry pending', - 'installations.feedback.zoomIn': 'Zoom in to search mapped installations', - 'installations.feedback.cached': 'Showing cached mapped sites', - 'installations.feedback.notLoaded': 'Mapped sites not loaded', - 'installations.feedback.loaded': 'Mapped sites loaded', - - // src/data/militaryAwareness.js panel + militaryAwarenessEngine.js reasons. - 'awareness.standbyReady': 'CONTEXT READY', - 'awareness.standbyOff': 'GLOBAL CONTEXT OFF', - 'awareness.standbySelect': 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION', - 'awareness.standbyEnable': 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY', - 'awareness.controlsAria': 'Global Context navigation', - 'awareness.previous': 'PREVIOUS', - 'awareness.focus': 'FOCUS', - 'awareness.next': 'NEXT', - 'awareness.previousTitle': - 'Previous — prior visited contact in the 250 km window', - 'awareness.nextTitle': - 'Next — nearest unvisited contact in the 250 km window', - 'awareness.note': - 'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.', - 'awareness.unavailableAria': 'Unavailable', - 'awareness.focusAria': 'Focus {label}', - 'awareness.cohort.flights': 'Flights', - 'awareness.cohort.military': 'Military flights', - 'awareness.cohort.vessels': 'AIS vessels', - 'awareness.cohort.installations': 'Mapped installations', - 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', - 'awareness.coverage.within': 'WITHIN {km} KM', - 'awareness.hdg': 'HDG {value}°', - 'awareness.brg': 'BRG {value}°', - 'awareness.brgUnknown': 'BRG —', - 'awareness.crs': 'CRS {value}°', - 'awareness.reason.feedUnavailable': 'feed unavailable', - 'awareness.reason.feedStale': 'feed stale', - 'awareness.reason.nearby': 'observed or mapped nearby context', - 'awareness.reason.none': 'no observed or mapped objects in current feeds', - - // src/data/regionalBrief.js WMO weather-code labels (cockpit copy). - 'weather.conditionsUnknown': 'CONDITIONS UNKNOWN', - 'weather.clear': 'CLEAR', - 'weather.partlyCloudy': 'PARTLY CLOUDY', - 'weather.overcast': 'OVERCAST', - 'weather.fog': 'FOG', - 'weather.drizzle': 'DRIZZLE', - 'weather.rain': 'RAIN', - 'weather.snow': 'SNOW', - 'weather.rainShowers': 'RAIN SHOWERS', - 'weather.snowShowers': 'SNOW SHOWERS', - 'weather.thunderstorm': 'THUNDERSTORM', - 'weather.mixed': 'MIXED CONDITIONS', - - 'missions.roster.keyboardHint': 'TAB PREVIEWS · ENTER / SPACE SELECTS', - - 'vessel.awaitingPositions': 'awaiting usable AIS positions…', - 'vessel.awaitingFirstMessage': 'awaiting first AIS message…', - 'missions.estDownrange': 'EST. DOWNRANGE', -}; diff --git a/src/i18n/locales/en/setup.js b/src/i18n/locales/en/setup.js deleted file mode 100644 index b3e2e3c1f..000000000 --- a/src/i18n/locales/en/setup.js +++ /dev/null @@ -1,213 +0,0 @@ -// English catalog — setup namespace (phase-1 seed). -// -// Owns first-launch onboarding, in-app key setup (Provider Settings), scene -// recipes, and voice-control chrome. Keys are namespace-relative; the registry -// prefixes them with `setup.`. -export const NAMESPACE = 'setup'; - -export default { - // index.html #first-run-launcher header/title/choices - 'firstRun.kicker': 'MISSION CONTROL · FIRST LAUNCH', - 'firstRun.title': 'Choose your first view', - 'firstRun.choice.contacts': 'LIVE CONTACTS', - 'firstRun.suppress': "Don't show this again", - // index.html #key-setup-chip / #key-setup dialog - 'keySetup.chip': 'POWER UP', - 'keySetup.kicker': 'GROUND STATION · PROVIDER SETTINGS', - 'keySetup.title': 'Power up the globe', - 'keySetup.apply': 'SAVE KEYS', - // src/keySetup.js submitUpdates() status announcement - 'keySetup.status.saving': 'Saving…', - - // ── Phase-2 static markup extraction (index.html), appended ────────────── - // The shell worker owns ALL index.html static markup in phase 2 - // (docs/TRANSLATORS.md cross-surface rule); these keys cover the - // support-worker surfaces (first-run, key setup, scene director) whose - // runtime paths land here in phase 3. - - // index.html #first-run-launcher - // Owner-authored persuasive line, pinned verbatim by firstRunExperience.test.mjs - // (unspaced em dash included) — keep the exact string when translating. - 'firstRun.description': - 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', - 'firstRun.choice.contactsSub': 'Aircraft, vessels and nearby intelligence', - 'firstRun.choice.spaceMissions': 'SPACE MISSIONS', - 'firstRun.choice.spaceMissionsSub': - 'Launches, spacecraft and orbital context', - 'firstRun.choice.explore': 'EXPLORE MANUALLY', - 'firstRun.choice.exploreSub': 'Begin with a clean globe', - 'firstRun.dismissHint': 'ESC to dismiss', - // Initial status tip; firstRunExperience.js swaps it for progress/errors. - 'firstRun.note': - 'Tip: the GEV MIC button in the dock lets you talk to the map.', - // NOT seeded: the environmental tile's / are pinned verbatim - // by firstRunExperience.test.mjs (and the title is painted from - // ENVIRONMENTAL_LABEL_CHOICE at init) — the support worker wires them in - // phase 3 together with the test update. - - // index.html #key-setup - 'keySetup.closeAriaLabel': 'Close key setup', - 'keySetup.description': - "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.", - 'keySetup.hint': 'ESC to close', - 'keySetup.note': - 'The Google Maps key buys the photorealistic planet — everything else stacks on top.', - - // index.html scene director panel chrome (src/scenes/director.js surface) - 'scenes.panelTitle': 'SCENES', - 'scenes.collapseTitle': 'Collapse panel', - 'scenes.recipeAriaLabel': 'Scene recipe', - 'scenes.new': 'NEW', - 'scenes.delete': 'DEL', - 'scenes.capture': 'CAPTURE SHOT', - 'scenes.updateShot': 'UPDATE SHOT', - 'scenes.start': 'START', - 'scenes.stop': 'STOP', - 'scenes.next': 'NEXT', - 'scenes.exportPresets': 'EXPORT PRESETS', - 'scenes.import': 'IMPORT', - 'scenes.runLog': 'RUN LOG', - 'scenes.statusReady': 'Ready', - - // ── Phase-3 runtime extraction (support surfaces), appended ─────────────── - - // src/firstRunExperience.js — mission busy lines and status/error copy. - // {detail} is the failed layer-id list (machine values, kept raw). - 'firstRun.busy.contacts': 'Starting live contacts…', - 'firstRun.busy.spaceMissions': 'Opening space missions…', - 'firstRun.busy.environmental': 'Scanning active events…', - 'firstRun.busy.working': 'Working…', - 'firstRun.status.failed': - 'Could not open that mission{detail}. Retry or explore manually.', - 'firstRun.status.storageBlocked': - 'This browser is blocking storage, so that could not be saved.', - // The environmental tile is painted at init from ENVIRONMENTAL_LABEL_CHOICE; - // the subcopy names BOTH feeds (pinned verbatim by firstRunExperience.test.mjs). - 'firstRun.choice.environmentalSub': - 'Live earthquakes and active fires, from USGS and NASA', - 'firstRun.environmentalTitle.environmental': 'ENVIRONMENTAL', - 'firstRun.environmentalTitle.earthWatch': 'EARTH WATCH', - 'firstRun.environmentalTitle.activeEvents': 'ACTIVE EVENTS', - - // src/keySetup.js — chip counter, status line, and remove confirm. - 'keySetup.chipWaiting': { - one: 'POWER UP · {count} KEY WAITING', - other: 'POWER UP · {count} KEYS WAITING', - }, - 'keySetup.chipReady': 'POWERED UP', - 'keySetup.status.saveFailed': 'Save failed ({status}).', - 'keySetup.status.saveFailedDetail': 'Save failed: {detail}', - 'keySetup.status.pasteFirst': 'Paste at least one key first.', - 'keySetup.status.saved': - 'Saved to {store}. Restarting — this page reloads itself.', - 'keySetup.status.removed': - 'Removed from {store}. Restarting — this page reloads itself.', - 'keySetup.store.pinokio': 'your app configuration', - 'keySetup.store.env': 'your local .env', - 'keySetup.confirm.remove': 'Remove this key from your saved configuration?', - - // src/mapStackChips.js — unavailable-chip tooltip and aria templates. - 'mapStack.fallbackName': 'This map stack', - 'mapStack.unavailableReason': '{label} is unavailable', - 'mapStack.unavailableAriaLabel': '{label} unavailable: {hint}', - - // src/scenes/director.js — built-in recipe DISPLAY names only. Recipe ids - // and the URL 'scene' param stay English; stored/renamed project titles are - // user data and render verbatim. - 'scenes.recipe.flightsRadar': 'Global Flights Radar', - 'scenes.recipe.orbitalWatch': 'Orbital Watch', - 'scenes.recipe.thermalThreats': 'Thermal Threat Board', - 'scenes.recipe.cityOverload': 'City Overload', - 'scenes.recipe.omnisciencePullback': 'Omniscience Pullback', - - // src/voice/gevRealtime.js — mic chrome and connection/execution STATUS - // text. Status enum keys (idle/connecting/…) are machine values; tool - // names/schemas and model-facing results stay English (keep-English - // boundary). Tier badges STD/MINI and the MIC/ON-OFF mic label stay machine - // identifiers this phase. - 'voice.status.idle': 'OFF', - 'voice.status.connecting': 'CONNECTING', - 'voice.status.listening': 'LISTENING', - 'voice.status.executing': 'EXECUTING', - 'voice.status.error': 'ERROR', - 'voice.status.sessionCostCap': 'Session ended — cost cap {cost}', - 'voice.detail.standby': 'VOICE STANDBY', - 'voice.detail.active': 'VOICE ACTIVE', - 'voice.detail.unavailable': 'VOICE UNAVAILABLE', - 'voice.detail.microphoneUnavailable': 'WebRTC microphone support unavailable', - 'voice.detail.requestingMicrophone': 'Requesting microphone', - 'voice.detail.holdSpaceTalk': 'Hold Space to talk', - 'voice.detail.releaseSpaceSend': 'Release Space to send', - 'voice.detail.askOrCommand': 'Ask or command', - 'voice.detail.voiceOff': 'Voice off', - 'voice.detail.runningCommand': 'Running command', - 'voice.detail.radioDidNotStart': 'Radio did not start', - 'voice.hint.default': - 'Hold Space to speak · tap Space to activate focused controls', - 'voice.error.sessionStart': 'Voice session could not be started.', - 'voice.error.trayTitle': 'VOICE SYSTEM ERROR', - 'voice.error.dismiss': 'DISMISS', - 'voice.error.hint': - 'Check microphone permission and network access, then try again.', - 'voice.kicker.agent': 'AI AGENT', - 'voice.kicker.control': 'VOICE CONTROL', - 'voice.tier.appliesNextSession': '{tier} applies next session', - 'voice.tier.buttonTitle': 'Voice model tier — applies next session', - 'voice.cost.buttonTitle': 'Estimated session cost', - 'voice.button.ariaLabel': - 'Voice control — activate to toggle voice; hold Space to speak', - - // ── Stage-4 repair pass: src/scenes/director.js status corpus, appended ─── - // Status/confirm lines and the default shot title. {scene}/{shot} carry - // stored project titles (user data, rendered verbatim); {mode} is the raw - // context-mode identifier (machine value, keep-English boundary). - 'scenes.status.captureCameraNotReady': - 'Cannot capture shot: camera not ready', - 'scenes.status.shotTitleDefault': 'Shot {n}', - 'scenes.status.captured': 'Captured: {scene} / {shot}', - 'scenes.status.selectShotFirst': 'Select a shot first', - 'scenes.status.updated': 'Updated: {scene} / {shot}', - 'scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?', - 'scenes.status.loaded': 'Loaded: {scene} / {shot}', - 'scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first', - 'scenes.status.noShotsToRun': 'No shots to run', - 'scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}', - 'scenes.status.runComplete': 'Scene run complete', - 'scenes.status.runError': 'Error: {message}', - 'scenes.status.contextExitFailed': - 'Could not exit {mode} — scene layers may be refused', - - // ── Stage-4 repair pass: keySetup dev-surface copy, appended ────────────── - // src/keySetup.js buildRow() remove chrome (dev-server-only surface). - 'keySetup.row.remove': 'REMOVE', - 'keySetup.row.removeTitle': "Remove {title} from this app's saved keys", - // src/keySetupCore.mjs keySetupRequirement(): availability sentence naming - // the registry env vars (machine values stay raw). - 'keySetup.requirement': 'Needs {envVars} — add it in Provider Settings', - // src/keySetupCore.mjs KEY_SETUP_KEYS `unlocks` copy, keyed by registry id; - // en values must stay byte-identical to the registry strings. - 'keySetup.unlocks.google-maps': 'The photorealistic 3D planet + place search', - 'keySetup.unlocks.google-maps-server': - 'Places context + Street View fallback; optional separate key', - 'keySetup.unlocks.openai': 'Voice control — API key or ChatGPT OAuth', - 'keySetup.unlocks.aisstream': 'Live ships, worldwide', - 'keySetup.unlocks.firms': 'Live active-fire detections', - 'keySetup.unlocks.tomtom': 'Real live traffic (keyless runs a simulation)', - 'keySetup.unlocks.cesium-ion': 'Bing imagery map stacks + world terrain', - 'keySetup.unlocks.opensky': - 'More flight-polling credits (anonymous works without)', - 'keySetup.unlocks.launch-library': 'Higher space-missions request allowance', - 'keySetup.unlocks.mapillary': - 'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token', - - 'scenes.status.actionFailed': 'Scene action failed', - - 'scenes.status.projectExported': 'Project exported', - - 'voice.tierNextSession': - 'Next session: {pendingId} — this session stays on {modelId}', - 'voice.tierSwitchHint': - 'Voice model: {pendingId} — click to switch to {target}; applies next session', - 'voice.costTooltip': - 'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.', -}; diff --git a/src/i18n/locales/en/shell.js b/src/i18n/locales/en/shell.js deleted file mode 100644 index 838fa0bd5..000000000 --- a/src/i18n/locales/en/shell.js +++ /dev/null @@ -1,82 +0,0 @@ -// English catalog — shell namespace (phase-1 seed). -// -// Owns the global application chrome: title bar, loader status, top-center -// actions, panel titles, and the shared global status surface. Keys are -// namespace-relative; the registry in src/i18n/index.js prefixes them with -// `shell.`. Values are plain strings or { one, other } plural variant objects -// with named {placeholder} interpolation. -export const NAMESPACE = 'shell'; - -export default { - // index.html #title-bar .subtitle - 'title.subtitle': 'NO PLACE LEFT BEHIND', - // index.html .loader-status (initial paint) - 'loading.initialStatus': 'Initializing photorealistic world...', - // src/main.js init() loaderStatus writes - 'loading.status.configuring': 'Configuring viewer...', - 'loading.status.tilesUnavailable': - 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', - 'loading.status.flying': 'Flying to Austin, TX...', - 'loading.status.restoring': 'Restoring shared view...', - // index.html #global-loading-label (initial) and - // src/loadingFeedback.js presentLoadingFeedback() labels - 'status.loadingLiveData': 'LOADING LIVE DATA', - 'status.loadComplete': 'LOAD COMPLETE', - 'status.loadFailed': 'LOAD FAILED', - 'status.loadCancelled': 'LOAD CANCELLED', - // index.html #traffic-sync-label (initial) and - // src/loadingFeedback.js reduceTrafficSyncFeedback() neutral fallback - 'status.trafficSyncing': 'syncing road network', - 'status.retryingIn': 'retrying in {seconds}s', - 'status.retryPending': 'retry pending', - // index.html #top-center-actions buttons - 'actions.clearLayers.ariaLabel': 'Clear selected data layers', - 'actions.clearLayers.title': 'Turn off all selected data layers', - 'actions.share.ariaLabel': 'Copy share link', - 'actions.resetView.ariaLabel': 'Reset to full globe view', - // index.html #data-toggles panel header - 'panels.dataLayers': 'DATA LAYERS', - // index.html .panel-collapse-btn on #data-panel - 'panels.collapseTitle': 'Collapse panel', - - // ── Phase-2 static markup extraction (index.html), appended ────────────── - // index.html #command-dock nav region - 'dock.ariaLabel': 'Navigation, voice, and visual preset controls', - // index.html #top-center-actions nav - 'actions.navAriaLabel': 'Globe actions', - 'actions.share.title': 'Copy share link', - 'actions.resetView.title': 'Reset camera and return to full globe view', - // index.html #cctv-sync-label (initial); src/ui.js setSplitFlapText() fallback - 'status.framesLoading': 'loading frames', - - // Locale selector (index.html #control-panel tray): the group container is - // static; its buttons are runtime-rendered by ui.js _initLocaleSelector() - // from the configured locale pair (keys at the end of this file). - 'locale.groupAriaLabel': 'Language', - - // ── Phase-3 runtime extraction (support worker), appended ───────────────── - // src/main.js init() loaderStatus write sites without a phase-1/2 key. - 'loading.status.tilesGoogle': 'Loading Google 3D Tiles...', - 'loading.status.tilesKeyless': 'Loading the keyless globe...', - 'loading.status.systems': 'Initializing systems...', - // src/loadingFeedback.js presentLoadingFeedback() labels beyond the phase-1 - // seeds. 'OpenStreetMap · Overpass' detail strings are provider names and - // stay verbatim at the call site. - 'status.liveDataOff': 'LIVE DATA OFF', - 'status.mappedSitesLoaded': 'MAPPED SITES LOADED', - 'status.retryingMappedSites': 'RETRYING MAPPED SITES', - 'status.fetchingMappedSites': 'FETCHING MAPPED SITES', - 'status.turningOffLiveData': 'TURNING OFF LIVE DATA', - 'status.refreshingLiveData': 'REFRESHING LIVE DATA', - - // ── Configurable locale pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE, - // vite.config.js client defines), appended ──────────────────────────── - // ui.js _initLocaleSelector() renders one runtime button per offered - // locale and reads shell.locale..ariaLabel for its aria-label. - // Only SHIPPED locales get a key: each locale PR appends its label to every - // shipped catalog (docs/TRANSLATORS.md), so an unshipped locale can never - // render a selector button. - 'locale.en.ariaLabel': 'Switch to English', - - 'credits.closeAria': 'Close data attribution', -}; diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json new file mode 100644 index 000000000..dd1164bb8 --- /dev/null +++ b/src/i18n/locales/ru.json @@ -0,0 +1,870 @@ +{ + "Flying to Austin, TX...": "Перелёт в Остин, Техас...", + "Restoring shared view...": "Восстановление общего вида...", + "Initializing systems...": "Инициализация систем...", + "Configuring viewer...": "Настройка просмотрщика...", + "Loading Google 3D Tiles...": "Загрузка Google 3D Tiles...", + "Loading the keyless globe...": "Загрузка глобуса без ключа...", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles недоступны ({detail}). Загрузка глобуса без ключа...", + "Close data attribution": "Закрыть атрибуцию данных", + "observed or mapped nearby context": "наблюдаемое или картографированное окружение", + "no observed or mapped objects in current feeds": "нет наблюдаемых или картографированных объектов в текущих потоках", + "feed unavailable": "поток данных недоступен", + "feed stale": "поток данных устарел", + "Brightest naked-eye objects — CelesTrak visual group": "Самые яркие объекты невооружённым глазом — визуальная группа CelesTrak", + "COMMS": "СВЯЗЬ", + "Broadband constellation shell — shown only in DENSE mode": "Оболочка широкополосной группировки — только в режиме ПЛОТНО", + "STATION": "СТАНЦИЯ", + "Crewed stations and their visiting vehicles": "Пилотируемые станции и прибывающие к ним корабли", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "Навигация GNSS — GPS, ГЛОНАСС, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Геостационарный пояс — связь и погода, неподвижно над экватором", + "VISUAL": "ВИДИМЫЕ", + "FISHING": "РЫБОЛОВНОЕ", + "TOWING": "БУКСИРОВКА", + "DREDGER": "ЗЕМСНАРЯД", + "DIVE OPS": "ВОДОЛАЗНОЕ", + "MILITARY": "ВОЕННОЕ", + "SAILING": "ПАРУСНИК", + "PLEASURE": "ПРОГУЛОЧНОЕ", + "PILOT": "ЛОЦМАН", + "SAR": "СПАСАТЕЛЬНОЕ", + "TUG": "БУКСИР", + "PORT TENDER": "ПОРТОВОЕ", + "ANTI-POLLUTION": "НЕФТЕСБОРЩИК", + "LAW ENFORCE": "ПАТРУЛЬНОЕ", + "MEDICAL": "МЕДИЦИНСКОЕ", + "HIGH-SPEED": "СКОРОСТНОЕ", + "PASSENGER": "ПАССАЖИРСКОЕ", + "CARGO": "ГРУЗОВОЕ", + "TANKER": "ТАНКЕР", + "OTHER": "ДРУГОЕ", + "Starting live contacts…": "Запуск контактов в реальном времени…", + "Opening space missions…": "Открытие космических миссий…", + "Scanning active events…": "Сканирование активных событий…", + "Live earthquakes and active fires, from USGS and NASA": "Землетрясения и активные пожары в реальном времени от USGS и NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Будто вы в запретной кабине — а потом понимаете: источники открыты, а данные настоящие.", + "ENVIRONMENTAL": "ПРИРОДА", + "EARTH WATCH": "ДОЗОР ЗЕМЛИ", + "ACTIVE EVENTS": "АКТИВНЫЕ СОБЫТИЯ", + "Working…": "Работаем…", + "Could not open that mission{detail}. Retry or explore manually.": "Не удалось открыть эту миссию{detail}. Повторите попытку или исследуйте вручную.", + "This browser is blocking storage, so that could not be saved.": "Браузер блокирует хранилище, поэтому сохранить не удалось.", + "SUMMARY": "СВОДКА", + "Awaiting telemetry...": "Ожидание телеметрии...", + "REC": "REC", + "REMOVE": "УДАЛИТЬ", + "Remove {title} from this app's saved keys": "Удалить {title} из сохранённых ключей этого приложения", + "POWER UP · {count} KEYS WAITING": { + "one": "ЗАПУСК · {count} КЛЮЧ В ОЖИДАНИИ", + "few": "ЗАПУСК · {count} КЛЮЧА В ОЖИДАНИИ", + "many": "ЗАПУСК · {count} КЛЮЧЕЙ В ОЖИДАНИИ", + "other": "ЗАПУСК · {count} КЛЮЧА В ОЖИДАНИИ" + }, + "POWERED UP": "ЗАПУЩЕНО", + "your app configuration": "ваша конфигурация приложения", + "your local .env": "ваш локальный .env", + "Saving…": "Сохранение…", + "Save failed ({status}).": "Не удалось сохранить ({status}).", + "Save failed: {detail}": "Не удалось сохранить: {detail}", + "Paste at least one key first.": "Сначала вставьте хотя бы один ключ.", + "Saved to {store}. Restarting — this page reloads itself.": "Сохранено: {store}. Перезапуск — страница перезагрузится сама.", + "Remove this key from your saved configuration?": "Удалить этот ключ из сохранённой конфигурации?", + "Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.", + "More flight-polling credits (anonymous works without)": "Больше кредитов на опрос рейсов (анонимно работает и без ключа)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Street Level». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его Client Token", + "Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий", + "The photorealistic 3D planet + place search": "Фотореалистичная 3D-планета + поиск мест", + "Places context + Street View fallback; optional separate key": "Контекст Places + резервный Street View; отдельный ключ — по желанию", + "Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth", + "Live ships, worldwide": "Суда в реальном времени по всему миру", + "Live active-fire detections": "Обнаружение активных пожаров в реальном времени", + "Real live traffic (keyless runs a simulation)": "Реальные пробки в реальном времени (без ключа — симуляция)", + "Bing imagery map stacks + world terrain": "Наборы карт Bing imagery + рельеф мира", + "Global Context navigation": "Навигация глобального контекста", + "Previous — prior visited contact in the 250 km window": "Назад — предыдущий посещённый контакт в радиусе 250 км", + "PREVIOUS": "ПРЕД.", + "FOCUS": "ФОКУС", + "Next — nearest unvisited contact in the 250 km window": "Далее — ближайший непосещённый контакт в радиусе 250 км", + "NEXT": "СЛЕД.", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Контекст из открытых источников. Отсутствие трансляций, незагруженные области карты или неотмеченные объекты — не доказательство отсутствия.", + "CONTEXT READY": "КОНТЕКСТ ГОТОВ", + "GLOBAL CONTEXT OFF": "ГЛОБАЛЬНЫЙ КОНТЕКСТ ВЫКЛ.", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "ВЫБЕРИТЕ РЕЙС, СУДНО ИЛИ ОБЪЕКТ НА КАРТЕ", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "ВКЛЮЧИТЕ ДЛЯ ЗАГРУЗКИ НАБЛЮДАЕМОГО / КАРТОГРАФИРОВАННОГО ОКРУЖЕНИЯ", + "Unavailable": "Недоступно", + "Focus {label}": "Фокус на {label}", + "HDG {value}°": "КУРС {value}°", + "BRG {value}°": "ПЕЛ. {value}°", + "BRG —": "ПЕЛ. —", + "CRS {value}°": "ПУТЬ {value}°", + "Military flights": "Военные рейсы", + "AIS vessels": "Суда AIS", + "Mapped installations": "Картографированные объекты", + "CURRENT VIEWPORT ONLY": "ТОЛЬКО ТЕКУЩАЯ ОБЛАСТЬ ОБЗОРА", + "WITHIN {km} KM": "В РАДИУСЕ {km} КМ", + "Flights": "Рейсы", + "syncing {count} city feeds...": "синхронизация городских потоков: {count}...", + "scanning nearby systems...": "поиск ближайших систем...", + "Station {id}": "Станция {id}", + "Station": "Станция", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 доступно {bikes} · своб. мест {docks} · вмест. {capacity}", + "⚠️ Not installed": "⚠️ Не установлена", + "⚠️ Not renting": "⚠️ Не выдаёт", + "⚠️ Not returning": "⚠️ Не принимает", + "HDG {value}° (ESTIMATED)": "КУРС {value}° (ОЦЕНКА)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "КАМЕР В ГОТОВНОСТИ: {count} · КАМЕРА НЕ ВЫБРАНА · НАЖМИТЕ КАМЕРУ ДЛЯ АКТИВАЦИИ", + "No cameras available in catalog.": "В каталоге нет доступных камер.", + "{city} CCTV": "CCTV: {city}", + "FOV {value}°": "ОБЗОР {value}°", + "COVERAGE {value}km²": "ОХВАТ {value} КМ²", + "OVERLAP {count} cams": "ПЕРЕКРЫТИЕ {count} кам.", + "ISOLATED VIEW": "ИЗОЛИРОВАННЫЙ ВИД", + "PROJ MONITOR": "ПРОЕКЦ. МОНИТОР", + "PROJ OFF": "ПРОЕКЦ. ВЫКЛ.", + "VIEWSHED": "ЗОНА ВИДИМОСТИ", + "CAL {value}": "КАЛИБР. {value}", + "SRC {value}": "ИСТ. {value}", + "{value} CONTEXT": "КОНТЕКСТ {value}", + "Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}", + "{value} conf": "увер. {value}", + "{value} ago": "{value} назад", + "sensor n/a": "сенсор н/д", + "FIRE · {frp} MW": "ПОЖАР · {frp} МВт", + "NIGHT": "НОЧЬ", + "▲ {frp} MW": "▲ {frp} МВт", + "FIRE": "ПОЖАР", + "FIRES": "ПОЖ.", + "max {value} MW": "макс. {value} МВт", + "new {value}": "последний {value}", + "just now": "только что", + "<1m ago": "<1 мин назад", + "{count}m ago": "{count} мин назад", + "{count}h ago": "{count} ч назад", + "STALE · cached {age}": "УСТАРЕЛ · кэш {age}", + "STALE": "УСТАРЕЛ", + "refreshing...": "обновление...", + "loading...": "загрузка...", + "KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ", + "LIVE · updated {age}": "ОНЛАЙН · обновлено {age}", + "Retrying mapped sites…": "Повторный запрос объектов…", + "Fetching mapped sites…": "Загрузка объектов…", + "Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты", + "Showing cached mapped sites": "Показаны объекты из кэша", + "Mapped sites not loaded": "Объекты не загружены", + "Mapped sites loaded": "Объекты загружены", + "Overpass rate-limited": "Overpass: превышен лимит запросов", + "Overpass timed out": "Overpass: истекло время ожидания", + "Overpass could not complete the query": "Overpass не смог завершить запрос", + "Overpass temporarily unavailable": "Overpass временно недоступен", + "loading mapped installation context": "загрузка контекста объектов", + "{reason} — retrying in {seconds}s": "{reason} — повтор через {seconds} с", + "{reason} — retry pending": "{reason} — ожидается повтор", + "MAPPED INSTALLATION": "КАРТОГРАФИРОВАННЫЙ ОБЪЕКТ", + "LAUNCH SITE · {site}": "МЕСТО ПУСКА · {site}", + "LAUNCH SITE": "МЕСТО ПУСКА", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "ОЖИДАНИЕ ПУСКА", + "LIFTOFF · {mission}": "СТАРТ · {mission}", + "ASCENT REPLAY · {mission}": "ПОВТОР ВЫВЕДЕНИЯ · {mission}", + "ASCENT ESTIMATE · {mission}": "РАСЧЁТНОЕ ВЫВЕДЕНИЕ · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "ВХОД В АТМОСФЕРУ / ВОЗВРАТ СТУПЕНИ · {mission}", + "ORBIT REPLAY · {mission}": "ПОВТОР ОРБИТЫ · {mission}", + "PAUSED · {title}": "ПАУЗА · {title}", + "UNSPECIFIED": "НЕ УКАЗАНО", + "UNAVAILABLE": "НЕДОСТУПНО", + "+{count} additional payload records": "+{count} доп. записи о полезной нагрузке", + "PAYLOAD DATA UNAVAILABLE": "ДАННЫЕ ПОЛЕЗНОЙ НАГРУЗКИ НЕДОСТУПНЫ", + "{value} KM DOWNRANGE": "{value} КМ ПО ТРАССЕ", + "POSITION UNAVAILABLE": "ПОЗИЦИЯ НЕДОСТУПНА", + "FLIGHT {count}": "ПОЛЁТ {count}", + "REUSED": "ЛЕТАВШАЯ", + "NO STAGE RE-ENTRY / RECOVERY DATA": "НЕТ ДАННЫХ О ВХОДЕ В АТМОСФЕРУ / ВОЗВРАТЕ СТУПЕНИ", + "{count} / 30D": "{count} / 30 ДН.", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "НЕТ МИССИЙ В ТЕКУЩЕМ 30-ДНЕВНОМ ОКНЕ", + "DATE UNAVAILABLE": "ДАТА НЕДОСТУПНА", + "UNSPECIFIED OPERATOR": "ОПЕРАТОР НЕ УКАЗАН", + "Select {mission}": "Выбрать {mission}", + "{value} KM": "{value} КМ", + "{value} KM/S": "{value} КМ/С", + "SELECTED SPACE MISSION": "ВЫБРАННАЯ КОСМИЧЕСКАЯ МИССИЯ", + "Show all missions": "Показать все миссии", + "Deselect mission": "Снять выбор миссии", + "MISSION": "МИССИЯ", + "STATUS · ": "СТАТУС · ", + "LAUNCH SITE · ": "МЕСТО ПУСКА · ", + "LAUNCH TIME · ": "ВРЕМЯ ПУСКА · ", + "ORBIT · ": "ОРБИТА · ", + "ASCENT PATH · ": "ТРАЕКТОРИЯ ВЫВЕДЕНИЯ · ", + "CURRENT DISTANCE FROM EARTH · ": "ТЕКУЩЕЕ РАССТОЯНИЕ ОТ ЗЕМЛИ · ", + "SATELLITE SPEED · ": "СКОРОСТЬ СПУТНИКА · ", + "PAYLOAD": "ПОЛЕЗНАЯ НАГРУЗКА", + "NAME": "НАЗВАНИЕ", + "TYPE": "ТИП", + "DESTINATION": "ПУНКТ НАЗНАЧЕНИЯ", + "STAGE / RE-ENTRY / RECOVERY": "СТУПЕНЬ / ВХОД В АТМОСФЕРУ / ВОЗВРАТ", + "STAGE": "СТУПЕНЬ", + "STATUS": "СТАТУС", + "FINAL POSITION": "КОНЕЧНОЕ ПОЛОЖЕНИЕ", + "REPLAY SPEED": "СКОРОСТЬ ПОВТОРА", + "Replay speed multiplier": "Множитель скорости повтора", + "REPLAY ASCENT": "ПОВТОРИТЬ ВЫВЕДЕНИЕ", + "Previous mission": "Предыдущая миссия", + "PREV": "ПРЕД.", + "Next mission": "Следующая миссия", + "SHOW ALL / DESELECT": "ПОКАЗАТЬ ВСЕ / СНЯТЬ ВЫБОР", + "Pause replay": "Приостановить повтор", + "Cancel replay": "Отменить повтор", + "EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ", + "PLANNED · ": "ПЛАН · ", + "SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ", + "RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА", + "Replay the estimated ascent with a following camera": "Повторить расчётное выведение с камерой сопровождения", + "Resume replay": "Возобновить повтор", + "T minus {seconds}": "T минус {seconds}", + "Preparing launch site": "Подготовка стартовой площадки", + "Liftoff": "Старт", + "Ascent replay": "Повтор выведения", + "Orbit replay": "Повтор: орбита", + ", paused": ", пауза", + "Alt unknown": "Высота неизвестна", + "Type unknown": "Тип неизвестен", + "Reg unknown": "Рег. номер неизвестен", + "Operator unknown": "Оператор неизвестен", + "All": "Все", + "News": "Новости", + "Talk": "Ток-радио", + "Weather / Emergency": "Погода / ЧС", + "Public Safety": "Общественная безопасность", + "Aviation / Marine": "Авиация / Море", + "Traffic / Transit": "Трафик / Транспорт", + "Music": "Музыка", + "Other": "Другое", + "Add the full Starlink broadband shell (thousands of extra points)": "Добавить полную широкополосную оболочку Starlink (тысячи дополнительных точек)", + "Loading the Starlink shell…": "Загрузка оболочки Starlink…", + "Starlink {detail} — click to retry": "Starlink: {detail} — нажмите, чтобы повторить", + "load failed": "ошибка загрузки", + "Showing the full Starlink shell — click for the core catalog only": "Показана полная оболочка Starlink — нажмите, чтобы оставить только основной каталог", + "DENSE ···": "ПЛОТНО ···", + "DENSE ✕": "ПЛОТНО ✕", + "DENSE": "ПЛОТНО", + "Syncing flow · Roads: {source}": "Синхронизация потока · Дороги: {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "ОНЛАЙН · Дороги: {source} · Дороги без потока скрыты", + "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", + "LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "СИМУЛЯЦИЯ · Дороги: OpenStreetMap · Поток: TomTom (нет совпадений)", + "SIMULATED — traffic service unreachable": "СИМУЛЯЦИЯ — сервис трафика недоступен", + "SIMULATED — add TomTom key for live": "СИМУЛЯЦИЯ — добавьте ключ TomTom для режима онлайн", + "POS: LIVE": "ПОЗ: ОНЛАЙН", + "POS: {time}Z": "ПОЗ: {time}Z", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}", + "VESSEL": "СУДНО", + "AIS: --": "AIS: --", + "awaiting first AIS position…": "ожидание первой позиции AIS…", + "awaiting usable AIS positions…": "ожидание пригодных позиций AIS…", + "awaiting first AIS message…": "ожидание первого сообщения AIS…", + "{label} unavailable: {hint}": "{label}: недоступно — {hint}", + "Needs {envVars} — add it in Provider Settings": "Нужен {envVars} — добавьте его в «Настройках провайдеров»", + "{label} is unavailable": "{label}: недоступно", + "This map stack": "Этот набор карт", + "Updated: {scene} / {shot}": "Обновлено: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Камера недоступна — сначала выйдите из кабины", + "No shots to run": "Нет кадров для воспроизведения", + "Error: {message}": "Ошибка: {message}", + "Project exported": "Проект экспортирован", + "Ready": "Готово", + "Could not exit {mode} — scene layers may be refused": "Не удалось выйти из режима {mode} — слои сцены могут быть отклонены", + "Loaded: {scene} / {shot}": "Загружено: {scene} / {shot}", + "Global Flights Radar": "Глобальный радар рейсов", + "Orbital Watch": "Орбитальный дозор", + "Thermal Threat Board": "Панель тепловых угроз", + "City Overload": "Перегрузка города", + "Omniscience Pullback": "Отъезд к всевидению", + "Cannot capture shot: camera not ready": "Не удалось сделать кадр: камера не готова", + "Shot {n}": "Кадр {n}", + "Captured: {scene} / {shot}": "Снято: {scene} / {shot}", + "Select a shot first": "Сначала выберите кадр", + "Running {index}/{total}: {scene} / {shot}": "Воспроизведение {index}/{total}: {scene} / {shot}", + "Scene run complete": "Воспроизведение сцены завершено", + "CCTV layer unavailable": "Слой CCTV недоступен", + "Link copied!": "Ссылка скопирована!", + "Copy failed": "Не удалось скопировать", + "VISUAL PRESETS": "ВИЗУАЛЬНЫЕ ПРЕСЕТЫ", + "LOCATION": "МЕСТО", + "DISPLAY": "ЭКРАН", + "CONTACT": "КОНТАКТ", + "CONTACTS — nearest planes · vessels · sites": "КОНТАКТЫ — ближайшие самолёты · суда · объекты", + "SPACE MISSIONS — launches & orbital assets": "КОСМИЧЕСКИЕ МИССИИ — пуски и орбитальные аппараты", + "LIVE SIGNALS": "СИГНАЛЫ ОНЛАЙН", + "MAP SOURCE": "ИСТОЧНИК КАРТЫ", + "Map source": "Источник карты", + "CCTV calibration saved": "Калибровка CCTV сохранена", + "ADJUST ON": "НАСТРОЙКА ВКЛ", + "ADJUST": "НАСТРОЙКА", + "CCTV calibration reset": "Калибровка CCTV сброшена", + "FRAME · UNAVAILABLE": "КАДР · НЕДОСТУПЕН", + "SOURCE · UNKNOWN": "ИСТОЧНИК · НЕИЗВЕСТЕН", + "FRAME · LOADING": "КАДР · ЗАГРУЗКА", + "VIEWSHED ON": "ВИДИМОСТЬ ВКЛ", + "COVERAGE ON": "ОХВАТ ВКЛ", + "COVERAGE OFF": "ОХВАТ ВЫКЛ", + "RAW PRIOR": "ИСХОДНАЯ ОЦЕНКА", + "AUTO HOP ON": "АВТОПЕРЕХОД ВКЛ", + "AUTO HOP OFF": "АВТОПЕРЕХОД ВЫКЛ", + "PROJECTION ON": "ПРОЕКЦИЯ ВКЛ", + "PROJECTION OFF": "ПРОЕКЦИЯ ВЫКЛ", + "CAL · EDITED (UNSAVED)": "КАЛИБР. · ИЗМЕНЕНО (НЕ СОХР.)", + "CAL · {badge}": "КАЛИБР. · {badge}", + "MONITOR": "МОНИТОР", + "OFF": "ВЫКЛ", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · КУРС {heading} · ОБЗОР {fov} · ДАЛЬН. {range} м · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "Загружена {count} камера · нажмите на камеру, чтобы активировать", + "few": "Загружено {count} камеры · нажмите на камеру, чтобы активировать", + "many": "Загружено {count} камер · нажмите на камеру, чтобы активировать", + "other": "Загружено {count} камеры · нажмите на камеру, чтобы активировать" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "Загружена {count} камера · включите CCTV, чтобы активировать", + "few": "Загружено {count} камеры · включите CCTV, чтобы активировать", + "many": "Загружено {count} камер · включите CCTV, чтобы активировать", + "other": "Загружено {count} камеры · включите CCTV, чтобы активировать" + }, + "Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках", + "Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.", + "No summary available.": "Сводка недоступна.", + "loading frames": "загрузка кадров", + "camera grid ready": "сетка камер готова", + "CCTV ON": "CCTV ВКЛ", + "CCTV OFF": "CCTV ВЫКЛ", + "CALIBRATED": "ОТКАЛИБРОВАНО", + "CURATED": "ВЫВЕРЕНО", + "Clearing selected data layers": "Очистка выбранных слоёв данных", + "Clear selected data layers": "Очистить выбранные слои данных", + "CONDITIONS UNKNOWN": "УСЛОВИЯ НЕИЗВЕСТНЫ", + "CLEAR": "ЯСНО", + "PARTLY CLOUDY": "ПЕРЕМЕННАЯ ОБЛАЧНОСТЬ", + "OVERCAST": "ПАСМУРНО", + "ACQUIRING REGIONAL NEWS": "ЗАГРУЗКА РЕГИОНАЛЬНЫХ НОВОСТЕЙ", + "REGIONAL NEWS UNAVAILABLE": "РЕГИОНАЛЬНЫЕ НОВОСТИ НЕДОСТУПНЫ", + "RESOLVING REGION": "ОПРЕДЕЛЕНИЕ РЕГИОНА", + "REGION UNAVAILABLE": "РЕГИОН НЕДОСТУПЕН", + "FOG": "ТУМАН", + "NO RECENT LOCATION MATCHES": "НЕТ СВЕЖИХ СОВПАДЕНИЙ ПО МЕСТУ", + "DRIZZLE": "МОРОСЬ", + "RAIN": "ДОЖДЬ", + "{domain} · {age}": "{domain} · {age}", + "SOURCE": "ИСТОЧНИК", + "SNOW": "СНЕГ", + "RAIN SHOWERS": "ЛИВНИ", + "SNOW SHOWERS": "ЛИВНЕВЫЙ СНЕГ", + "THUNDERSTORM": "ГРОЗА", + "CLOUD {pct}%": "ОБЛАЧНОСТЬ {pct}%", + "CLOUD UNKNOWN": "ОБЛАЧНОСТЬ НЕИЗВЕСТНА", + "MIXED CONDITIONS": "СМЕШАННЫЕ УСЛОВИЯ", + "{source} · LOCATION QUERY": "{source} · ЗАПРОС ПО МЕСТУ", + "REGIONAL NEWS": "РЕГИОНАЛЬНЫЕ НОВОСТИ", + "CYCLE ON": "АВТОЛИСТАНИЕ ВКЛ", + "CYCLE OFF": "АВТОЛИСТАНИЕ ВЫКЛ", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Остановить автоматическое листание страниц. Кнопки ПРЕД./СЛЕД. и вкладки SIG/NEWS/LOCAL остаются доступны.", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Автоматически листать страницы брифинга каждые 9 секунд (Сигналы → Новости → Местное). При наведении или фокусе на панели листание приостанавливается. Данные сигналов в реальном времени в любом случае обновляются непрерывно.", + "{cohort}, Unavailable": "{cohort}, недоступно", + "AHEAD": "ПО КУРСУ", + "{side} {angle}": "{side} {angle}", + "L": "Л", + "R": "П", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} ИСТОЧНИК ДАННЫХ НЕИЗВЕСТЕН · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "few": "{count} ИСТОЧНИКА ДАННЫХ НЕИЗВЕСТНЫ · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "many": "{count} ИСТОЧНИКОВ ДАННЫХ НЕИЗВЕСТНО · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "other": "{count} ИСТОЧНИКА ДАННЫХ НЕИЗВЕСТНО · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "ДОСТУПНЫЕ ДАННЫЕ АКТУАЛЬНЫ · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "CONTEXT STANDBY": "КОНТЕКСТ В ОЖИДАНИИ", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ВКЛЮЧИТЕ ГЛОБАЛЬНЫЙ КОНТЕКСТ ДЛЯ ПИНГОВ ПОБЛИЗОСТИ", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ПОТЕРЯН · ПОСЛЕДНИЕ ИЗВЕСТНЫЕ ДАННЫЕ · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "CONTACT LOST · {subject}": "КОНТАКТ ПОТЕРЯН · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "ОБЪЕКТ ВЫПАЛ ИЗ ПОТОКА · ПОКАЗАНЫ ПОСЛЕДНИЕ ДАННЫЕ", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "НЕТ ДОСТУПНОГО ПРИМЕРА", + "ACQUIRING SURFACE": "ЗАХВАТ ПОВЕРХНОСТИ", + "SURFACE FALLBACK": "РЕЗЕРВНАЯ ПОВЕРХНОСТЬ", + "STALE FEED": "ПОТОК УСТАРЕЛ", + "LIVE TRACK": "ТРЕК ОНЛАЙН", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС СОВМЕЩЁН", + "COMMERCIAL": "ГРАЖДАНСКИЙ", + "UNKNOWN": "НЕИЗВЕСТНО", + "ARROW · ESTIMATED DIRECTION": "СТРЕЛКА · РАСЧЁТНОЕ НАПРАВЛЕНИЕ", + "ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ", + "DEST {bearing}": "НАЗН. {bearing}", + "Disable cockpit weather effects": "Выключить погодные эффекты кабины", + "Enable cockpit weather effects": "Включить погодные эффекты кабины", + "ON": "ВКЛ", + "Night vision": "Ночное видение", + "Thermal": "Тепловизор", + "Anime": "Аниме", + "Noir": "Нуар", + "Snow": "Снег", + "Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.", + "Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего", + "AIRCRAFT": "САМОЛЁТ", + "Expand Contact panel": "Развернуть панель контактов", + "Collapse Contact panel": "Свернуть панель контактов", + "Expand contact panel": "Развернуть панель контактов", + "Collapse contact panel": "Свернуть панель контактов", + "Collapse cockpit briefing panel": "Свернуть панель брифинга кабины", + "Expand cockpit briefing panel": "Развернуть панель брифинга кабины", + "Collapse briefing panel": "Свернуть панель брифинга", + "Expand briefing panel": "Развернуть панель брифинга", + "OBSERVED / MAPPED PINGS": "НАБЛЮДАЕМЫЕ / НАНЕСЁННЫЕ ПИНГИ", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "СОБЫТИЯ С ИСТОЧНИКАМИ · БЕЗ СИНТЕТИЧЕСКИХ НОВОСТЕЙ", + "LATEST LOCATION-MATCHED REPORTING": "СВЕЖИЕ НОВОСТИ ПО МЕСТУ", + "LOCAL INFO": "МЕСТНЫЕ ДАННЫЕ", + "PLACE / CONDITIONS / POSITION": "МЕСТО / УСЛОВИЯ / ПОЗИЦИЯ", + "TIME UNKNOWN": "ВРЕМЯ НЕИЗВЕСТНО", + "{count}M AGO": "{count} МИН НАЗАД", + "{count}H AGO": "{count} Ч НАЗАД", + "{count}D AGO": "{count} ДН. НАЗАД", + "DIR UNKNOWN": "НАПР. НЕИЗВЕСТНО", + "{aircraftClass} · CURRENT": "{aircraftClass} · ТЕКУЩИЙ", + "MILITARY FLIGHT": "ВОЕННЫЙ РЕЙС", + "COMMERCIAL FLIGHT": "ГРАЖДАНСКИЙ РЕЙС", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "ДИСТАНЦИЯ НЕИЗВЕСТНА", + "{count} INPUTS UNKNOWN": { + "one": "{count} ИСТОЧНИК ДАННЫХ НЕИЗВЕСТЕН", + "few": "{count} ИСТОЧНИКА ДАННЫХ НЕИЗВЕСТНЫ", + "many": "{count} ИСТОЧНИКОВ ДАННЫХ НЕИЗВЕСТНО", + "other": "{count} ИСТОЧНИКА ДАННЫХ НЕИЗВЕСТНО" + }, + "SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН", + "Select flight {title}": "Выбрать рейс {title}", + "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", + "{label} · COURSE {heading}°": "{label} · КУРС {heading}°", + "Restore real aircraft": "Вернуть реальный самолёт", + "Reclassify as TR-3B": "Переклассифицировать как TR-3B", + "No selected data layers": "Нет выбранных слоёв данных", + "{count} data layers could not be cleared": { + "one": "Не удалось очистить {count} слой данных", + "few": "Не удалось очистить {count} слоя данных", + "many": "Не удалось очистить {count} слоёв данных", + "other": "Не удалось очистить {count} слоя данных" + }, + "Cleared {count} data layers": { + "one": "Очищен {count} слой данных", + "few": "Очищено {count} слоя данных", + "many": "Очищено {count} слоёв данных", + "other": "Очищено {count} слоя данных" + }, + "Selected data layers could not be cleared": "Не удалось очистить выбранные слои данных", + "Context could not restore every layer; try again": "Контекст: не удалось восстановить все слои; попробуйте снова", + "Nearby installations refreshed": "Ближайшие объекты обновлены", + "Nearby installations could not be refreshed; try again": "Не удалось обновить ближайшие объекты; попробуйте снова", + "Contacts could not complete the requested transition; try again": "Не удалось переключить режим «Контакты»; попробуйте снова", + "Space Missions could not complete the requested transition; try again": "Не удалось переключить режим «Космические миссии»; попробуйте снова", + "{layerId} could not {action} cleanly": "Не удалось корректно {action} слой {layerId}", + "start": "запустить", + "stop": "остановить", + "Space Missions cancellation could not restore the previous layer state": "Не удалось восстановить прежнее состояние слоёв после отмены космических миссий", + "That layer is unavailable in the current Context mode": "Этот слой недоступен в текущем режиме контекста", + "{mode} could not start because another layer did not stop cleanly": "Не удалось запустить режим «{mode}»: другой слой не остановился корректно", + "Space Missions": "Космические миссии", + "Context": "Контекст", + "LOADING": "ЗАГРУЗКА", + "DEGRADED": "ПЕРЕБОИ", + "PARTIAL": "ЧАСТИЧНО", + "FALLBACK": "РЕЗЕРВ", + "Live Flights": "Рейсы в реальном времени", + "Military Flights": "Военные рейсы", + "Earthquakes (24h)": "Землетрясения (24 ч)", + "Satellites": "Спутники", + "Space Missions (30d)": "Космические миссии (30 дн.)", + "Street Traffic": "Уличный трафик", + "CCTV": "CCTV", + "Radio": "Радио", + "Bikeshare": "Велошеринг", + "Live AIS Vessels": "Суда AIS в реальном времени", + "Mapped Installations": "Объекты на карте", + "Global Context": "Глобальный контекст", + "Datacenters": "Дата-центры", + "Dams": "Плотины", + "Submarine Cables": "Подводные кабели", + "FIRMS Active Fires": "Активные пожары FIRMS", + "ENABLING": "ВКЛЮЧЕНИЕ", + "DISABLING": "ВЫКЛЮЧЕНИЕ", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "НЕОПРЕД. · {source} · требуется сверка состояния", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · повтор через {seconds} с", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "никогда", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "принято записей: {accepted} из {raw}", + "incomplete snapshot": "неполный снимок", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · повтор через {seconds} с", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "НЕОПРЕД.", + "Data source unavailable": "Источник данных недоступен", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count} с назад", + "📍 Location: --": "📍 Место: --", + "Landmark: --": "Ориентир: --", + "Searched location": "Найденное место", + "ORBIT": "ОРБИТА", + "Fly to a POI first": "Сначала перелетите к точке интереса", + "Reset to full globe view": "Сбросить к виду всего глобуса", + "Reset cockpit to full globe view": "Сбросить кабину к виду всего глобуса", + "Resetting to full globe view": "Сброс к виду всего глобуса", + "Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса", + "Location not found": "Место не найдено", + "Search failed": "Не удалось выполнить поиск", + "MAP": "КАРТА", + "panel": "панель", + "Expand {name}": "Развернуть {name}", + "Collapse {name}": "Свернуть {name}", + "Expand Radio": "Развернуть радио", + "Collapse Radio": "Свернуть радио", + "Expand Radio section": "Развернуть раздел радио", + "Collapse Radio section": "Свернуть раздел радио", + "Panel layout updated — positions reset to new defaults": "Макет панелей обновлён — позиции сброшены к новым значениям по умолчанию", + "NO STATIONS": "НЕТ СТАНЦИЙ", + "NO STATION AVAILABLE": "НЕТ ДОСТУПНЫХ СТАНЦИЙ", + "{name}, station {index} of {total}": "{name}, станция {index} из {total}", + "No station available": "Нет доступной станции", + "OFF AIR": "НЕ В ЭФИРЕ", + "STATION UNAVAILABLE": "СТАНЦИЯ НЕДОСТУПНА", + "Radio could not {action} cleanly": "Не удалось корректно {action} радио", + "Collapse Cockpit display options": "Свернуть параметры экрана кабины", + "Expand Cockpit display options": "Развернуть параметры экрана кабины", + "Collapse Cockpit Radio controls": "Свернуть управление радио кабины", + "Expand Cockpit Radio controls": "Развернуть управление радио кабины", + "Close compact Radio controls": "Закрыть компактное управление радио", + "Open compact Radio controls": "Открыть компактное управление радио", + "DIRECTORY BAND": "ДИАПАЗОН КАТАЛОГА", + "{category} BAND": "ДИАПАЗОН {category}", + "NO STATION SELECTED": "СТАНЦИЯ НЕ ВЫБРАНА", + "Directory metadata only": "Только метаданные каталога", + "Loading station directory…": "Загрузка каталога станций…", + "Choose a globe marker or use next.": "Выберите маркер на глобусе или нажмите «След.».", + "TAGS · {tags}": "ТЕГИ · {tags}", + "Pause": "Приостановить", + "Resume": "Продолжить", + "Play": "Воспроизвести", + "selected": "выбранную", + "nearest": "ближайшую", + "{action} {target} radio station": "{action} {target} радиостанцию", + "RADIO STATE UNCERTAIN": "СОСТОЯНИЕ РАДИО НЕОПРЕДЕЛЁННО", + "SYNCING DIRECTORY": "СИНХРОНИЗАЦИЯ КАТАЛОГА", + "RADIO READY": "РАДИО ГОТОВО", + "SYNCING": "СИНХРОНИЗАЦИЯ", + "READY": "ГОТОВО", + "station": "станция", + " · stale/degraded directory": " · каталог устарел/с перебоями", + " · degraded directory": " · каталог с перебоями", + " · stale directory": " · каталог устарел", + " · outside current filter": " · вне текущего фильтра", + "Ready — playback starts only from your action": "Готово — воспроизведение начнётся только по вашей команде", + "Radio off": "Радио выключено", + "Connecting directly to broadcaster…": "Прямое подключение к вещателю…", + "Buffering broadcaster stream…": "Буферизация потока вещателя…", + "Playing {station}": "Играет {station}", + "Paused {station}": "{station} на паузе", + "Broadcaster stream unavailable": "Поток вещателя недоступен", + " · muted during voice interaction": " · приглушено во время голосовой сессии", + " · restoring volume after voice": " · восстановление громкости после голосовой сессии", + " · static indicates no broadcaster audio": " · шум означает отсутствие аудио вещателя", + " · tuning static until broadcaster starts": " · шум настройки, пока не начнётся вещание", + "Station unavailable after directory refresh — choose another channel": "Станция недоступна после обновления каталога — выберите другой канал", + "Radio is enabling…": "Радио включается…", + "Radio is disabling…": "Радио выключается…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Состояние радио не определено — нажмите «Включить» или «Выключить» для сверки", + "SYNC": "СИНХР.", + "RECONCILE": "СВЕРИТЬ", + "DISABLE": "ВЫКЛЮЧИТЬ", + "ENABLE": "ВКЛЮЧИТЬ", + "Reconcile Radio — lifecycle uncertain": "Сверить радио — состояние не определено", + "Disable Radio": "Выключить радио", + "Enable Radio": "Включить радио", + "Scene action failed": "Не удалось выполнить действие со сценой", + "Delete shot \"{shot}\"?": "Удалить кадр «{shot}»?", + "ACQUIRING": "ЗАХВАТ", + "SHARED {subject}": "ПО ССЫЛКЕ: {subject}", + "entity": "объект", + "Shared {subject} follow expired": "Срок отслеживания {subject} по ссылке истёк", + "Shared {subject} could not be restored — feed unavailable": "Не удалось восстановить {subject} по ссылке — поток данных недоступен", + "Shared {subject} is unavailable": "{subject} по ссылке недоступен", + "LOADING LIVE DATA": "ЗАГРУЗКА ОНЛАЙН-ДАННЫХ", + "LOAD COMPLETE": "ЗАГРУЗКА ЗАВЕРШЕНА", + "LOAD CANCELLED": "ЗАГРУЗКА ОТМЕНЕНА", + "LOAD FAILED": "ОШИБКА ЗАГРУЗКИ", + "LIVE DATA OFF": "ОНЛАЙН-ДАННЫЕ ВЫКЛ.", + "MAPPED SITES LOADED": "ОБЪЕКТЫ ЗАГРУЖЕНЫ", + "RETRYING MAPPED SITES": "ПОВТОРНАЯ ЗАГРУЗКА ОБЪЕКТОВ", + "FETCHING MAPPED SITES": "ЗАГРУЗКА ОБЪЕКТОВ", + "TURNING OFF LIVE DATA": "ОТКЛЮЧЕНИЕ ОНЛАЙН-ДАННЫХ", + "REFRESHING LIVE DATA": "ОБНОВЛЕНИЕ ОНЛАЙН-ДАННЫХ", + "syncing road network": "синхронизация дорожной сети", + "retrying in {seconds}s": "повтор через {seconds} с", + "retry pending": "повтор ожидается", + "Contact cockpit summary": "Сводка по контакту в кабине", + "Cockpit briefing carousel": "Карусель брифингов кабины", + "Cockpit display and Radio controls": "Управление экраном и радио кабины", + "Previous cockpit vision style": "Предыдущий стиль обзора кабины", + "Previous vision style": "Предыдущий стиль обзора", + "Current cockpit vision style: NORMAL. Activate for next style.": "Текущий стиль обзора кабины: NORMAL. Активируйте для следующего стиля.", + "Current style: NORMAL — click for next": "Текущий стиль: NORMAL — нажмите для следующего", + "Next cockpit vision style": "Следующий стиль обзора кабины", + "Next vision style": "Следующий стиль обзора", + "Previous briefing page": "Предыдущая страница брифинга", + "Next briefing page": "Следующая страница брифинга", + "Show Live Signals": "Показать сигналы в реальном времени", + "Show Regional News": "Показать региональные новости", + "Show Local Info": "Показать местную информацию", + "Previous filtered radio station": "Предыдущая отфильтрованная радиостанция", + "Play selected radio station": "Воспроизвести выбранную радиостанцию", + "Next filtered radio station": "Следующая отфильтрованная радиостанция", + "Exit cockpit and return to full globe view": "Выйти из кабины и вернуться к виду всего глобуса", + "Exit cockpit view": "Выйти из кабины", + "Estimated destination direction": "Расчётное направление на пункт назначения", + "OPTICAL PLANE · 01": "ОПТИЧЕСКАЯ ПЛОСКОСТЬ · 01", + "VISOR LOCK · ACTIVE": "БЛОКИРОВКА ВИЗОРА · АКТИВНА", + "Cockpit vision style": "Стиль обзора кабины", + "Current aircraft heading": "Текущий курс самолёта", + "Nearby cohort counts": "Счётчики контактов поблизости", + "Cockpit display options": "Параметры экрана кабины", + "Cockpit compact Radio controls": "Компактное управление радио кабины", + "Cockpit Radio volume": "Громкость радио кабины", + "Contact navigation": "Навигация по контактам", + "Cockpit briefing controls": "Управление брифингом кабины", + "View switcher": "Переключатель вида", + "Aircraft cockpit view": "Вид кабины самолёта", + "Estimated flight plan": "Расчётный план полёта", + "Live signals": "Сигналы в реальном времени", + "Latest regional news": "Последние региональные новости", + "Location-based information": "Информация по текущему месту", + "LEVEL": "ГОРИЗОНТ", + "DEST ---°": "НАЗН. ---°", + "CURRENT": "ТЕКУЩИЙ", + "NEAREST OBSERVED / MAPPED": "БЛИЖАЙШИЕ НАБЛЮДАЕМЫЕ / НА КАРТЕ", + "GROUND SPEED · KTS": "ПУТЕВАЯ СКОРОСТЬ · УЗ", + "ALTITUDE · FT": "ВЫСОТА · ФТ", + "FIRST PERSON": "ОТ ПЕРВОГО ЛИЦА", + "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН", + "GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ", + "ALTITUDE": "ВЫСОТА", + "CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · БЕЗОПАСНОСТЬ НЕ ПОДТВЕРЖДЕНА", + "ESTIMATED FLIGHT PLAN": "РАСЧЁТНЫЙ ПЛАН ПОЛЁТА", + "FROM": "ОТКУДА", + "TO": "КУДА", + "TEMP": "ТЕМП.", + "WIND": "ВЕТЕР", + "SKY": "НЕБО", + "PRECIP": "ОСАДКИ", + "RADIO": "РАДИО", + "VOLUME": "ГРОМКОСТЬ", + "RESET": "СБРОС", + "EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ", + "CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ", + "Expand Visual Presets": "Развернуть визуальные пресеты", + "Pin visual presets": "Закрепить визуальные пресеты", + "Keep visual presets open": "Держать визуальные пресеты открытыми", + "Show the globe without a visual filter.": "Показывает глобус без визуального фильтра.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Имитирует CRT-экран с зелёным люминофором, строками развёртки и кривизной экрана.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Имитирует приборы ночного видения с зелёным усилением изображения и виньеткой трубки.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Имитирует тепловой контраст в стиле FLIR. Для цвета увеличьте Ironbow.", + "Apply bright cel-shaded color and illustrated outlines.": "Применяет яркие cel-shading-цвета и рисованные контуры.", + "Apply high-contrast monochrome film-noir grading.": "Применяет высококонтрастную монохромную цветокоррекцию в стиле нуар.", + "Add a cold, snowy whiteout treatment to the scene.": "Добавляет в сцену холодный снежный эффект «белой мглы».", + "Expand LOCATION": "Развернуть панель «Место»", + "Collapse panel": "Свернуть панель", + "Pin location tray": "Закрепить панель места", + "Keep location tray open": "Держать панель места открытой", + "Search any location": "Поиск любого места", + "Navigation, voice, and visual preset controls": "Управление навигацией, голосом и визуальными пресетами", + "Language": "Язык", + "Search location by name or coordinates": "Поиск места по названию или координатам", + "Search any location...": "Поиск любого места...", + "Normal": "Обычный", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "Style": "Стиль", + "STATION SITE": "САЙТ СТАНЦИИ", + "Open detailed Radio controls": "Открыть подробное управление радио", + "Previous station": "Предыдущая станция", + "Next station": "Следующая станция", + "Expand panel": "Развернуть панель", + "CONTACTS": "КОНТАКТЫ", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Перебирает ближайшие контакты выбранного типа — самолёты, суда, объекты. Спутники отслеживаются отдельно.", + "SEARCH NEARBY SITES": "ПОИСК ОБЪЕКТОВ РЯДОМ", + "Reclassify tracked contact as TR-3B": "Переклассифицировать отслеживаемый контакт как TR-3B", + "Previous filtered station": "Предыдущая отфильтрованная станция", + "PLAY": "ПУСК", + "Play selected station": "Воспроизвести выбранную станцию", + "Next filtered station": "Следующая отфильтрованная станция", + "STOP": "СТОП", + "Stop radio playback": "Остановить воспроизведение радио", + "Compact Radio controls": "Компактное управление радио", + "Context mode": "Режим контекста", + "Contact Context actions": "Действия контекста контакта", + "Available Space Missions": "Доступные космические миссии", + "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "Radio playback": "Воспроизведение радио", + "Compact Radio volume": "Громкость компактного радио", + "Radio volume": "Громкость радио", + "STATION TAG": "ТЕГ СТАНЦИИ", + "DRAG TO TUNE": "ТЯНИТЕ ДЛЯ НАСТРОЙКИ", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ПРОСМОТР · ENTER / SPACE — ВЫБОР", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "После нажатия «Пуск» аудио подключается напрямую к вещателю. Ваш IP виден этому вещателю.", + "Internet radio and local SDR": "Интернет-радио и локальный SDR", + "Filter stations by station tag": "Фильтровать станции по тегу", + "CONTEXT": "КОНТЕКСТ", + "SPACE MISSIONS": "КОСМИЧЕСКИЕ МИССИИ", + "COCKPIT": "КАБИНА", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "ВЫБЕРИТЕ КОНТАКТЫ, ЧТОБЫ ЗАГРУЗИТЬ НАБЛЮДАЕМОЕ / КАРТОГРАФИРОВАННОЕ ОКРУЖЕНИЕ", + "SELECT A MISSION TO INSPECT": "ВЫБЕРИТЕ МИССИЮ ДЛЯ ПРОСМОТРА", + "Enable Radio, then choose a globe marker or use next.": "Включите радио, затем выберите маркер на глобусе или переключите на следующую станцию.", + "ALL · DRAG THE NEEDLE": "ВСЕ · ТЯНИТЕ СТРЕЛКУ", + "SNAPS TO AVAILABLE STATIONS": "ПРИВЯЗКА К ДОСТУПНЫМ СТАНЦИЯМ", + "SELECT CONTEXT": "ВЫБЕРИТЕ КОНТЕКСТ", + "CONTACTS CONTEXT OFF": "КОНТЕКСТ КОНТАКТОВ ВЫКЛЮЧЕН", + "AVAILABLE MISSIONS": "ДОСТУПНЫЕ МИССИИ", + "Intelligence HUD (H)": "HUD разведки (H)", + "Detection Overlay (D)": "Слой детекции (D)", + "Detection overlay": "Слой детекции", + "Elastic": "Гибкое", + "Weighted": "Взвешенное", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D-самолёты — издалека плоские иконки, вблизи 3D-модели", + "Proximity": "Вблизи", + "Scope — the circular viewport mask": "Прицел — круглая маска области просмотра", + "Hide UI chrome": "Скрыть элементы интерфейса", + "Bloom / Glow": "Bloom / свечение", + "Sharpening": "Повышение резкости", + "EXIT CLEAN VIEW": "ВЫЙТИ ИЗ ЧИСТОГО ВИДА", + "Return UI controls": "Вернуть управление интерфейсом", + "Detection label allocation": "Размещение меток детекции", + "3D model coverage": "Охват 3D-моделей", + "Detection label density": "Плотность меток детекции", + "Detection fade distance": "Дистанция затухания детекции", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Дистанция затухания мирового слоя за пределами маски в процентах от её радиуса", + "Detection opacity outside the keyhole": "Непрозрачность детекции за пределами маски", + "World-overlay label and card opacity beyond the fade distance": "Непрозрачность меток и карточек мирового слоя за пределами дистанции затухания", + "Scope edge feather": "Растушёвка края прицела", + "Scope edge feather as a percentage of the keyhole radius": "Растушёвка края прицела в процентах от радиуса маски", + "Bloom intensity": "Интенсивность bloom", + "Sharpen intensity": "Интенсивность резкости", + "Density": "Плотность", + "Fade": "Затухание", + "Outside": "Снаружи", + "Tactical": "Тактический", + "Operator": "Операторский", + "Minimal": "Минимальный", + "HUD layout": "Макет HUD", + "Layout": "Макет", + "Allocation": "Размещение", + "PARAMETERS": "ПАРАМЕТРЫ", + "Models": "Модели", + "Scope": "Прицел", + "Feather": "Растушёвка", + "Celestial": "Небо", + "Clean UI": "Чистый интерфейс", + "Bloom": "Bloom", + "Sharpen": "Резкость", + "Initializing photorealistic world...": "Инициализация фотореалистичного мира...", + "NEAREST": "БЛИЖАЙШАЯ", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Перетащите камеру в мире: кольца вращают, стрелки перемещают, маркеры задают дальность и угол обзора", + "Heading (compass °) — click to type": "Курс (° по компасу) — нажмите для ввода", + "Pitch (° up/down) — click to type": "Тангаж (° вверх/вниз) — нажмите для ввода", + "Horizontal FOV (°) — click to type": "Горизонтальный угол обзора (°) — нажмите для ввода", + "Range / monitor-plane distance (m) — click to type": "Дальность / расстояние до плоскости монитора (м) — нажмите для ввода", + "Mount height above ground (m) — click to type": "Высота крепления над землёй (м) — нажмите для ввода", + "North offset from catalog position (m) — click to type": "Смещение на север от позиции каталога (м) — нажмите для ввода", + "East offset from catalog position (m) — click to type": "Смещение на восток от позиции каталога (м) — нажмите для ввода", + "SAVE CAL": "СОХРАНИТЬ КАЛИБР.", + "RESET CAL": "СБРОСИТЬ КАЛИБР.", + "NEW": "НОВАЯ", + "DEL": "УДАЛ.", + "CAPTURE SHOT": "СДЕЛАТЬ КАДР", + "UPDATE SHOT": "ОБНОВИТЬ КАДР", + "START": "СТАРТ", + "EXPORT PRESETS": "ЭКСПОРТ ПРЕСЕТОВ", + "IMPORT": "ИМПОРТ", + "RUN LOG": "ЖУРНАЛ ЗАПУСКОВ", + "CALIBRATION": "КАЛИБРОВКА", + "Camera pose — click a value to type": "Поза камеры — нажмите на значение для ввода", + "SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ", + "CCTV camera": "Камера CCTV", + "Scene recipe": "Рецепт сцены", + "DATA LAYERS": "СЛОИ ДАННЫХ", + "SCENES": "СЦЕНЫ", + "Close key setup": "Закрыть настройку ключей", + "SAVE KEYS": "СОХРАНИТЬ КЛЮЧИ", + "Power up the globe": "Запустите глобус", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "Глобус уже работает без ключей. Каждый ключ ниже включает ещё один реальный поток данных — вставьте ключ, и он сохранится в локальной конфигурации приложения, после чего сервер перезапустится сам. Серверные ключи остаются на этом компьютере; Google Maps и Cesium ion работают в браузере, поэтому их ключи нужно ограничить на стороне провайдера. Ключи, настроенные в другом месте, отображаются, но не изменяются.", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Ключ Google Maps открывает фотореалистичную планету — всё остальное надстраивается поверх неё.", + "POWER UP": "ЗАПУСК", + "GROUND STATION · PROVIDER SETTINGS": "НАЗЕМНАЯ СТАНЦИЯ · НАСТРОЙКИ ПРОВАЙДЕРОВ", + "ESC to close": "ESC — закрыть", + "Turn off all selected data layers": "Выключить все выбранные слои данных", + "Copy share link": "Скопировать ссылку", + "Reset camera and return to full globe view": "Сбросить камеру и вернуть полный вид глобуса", + "Visible map targets": "Видимые цели на карте", + "Globe actions": "Действия с глобусом", + "NO PLACE LEFT BEHIND": "НИ ОДНА ТОЧКА НЕ ОСТАНЕТСЯ ПОЗАДИ", + "ACTIVE STYLE": "АКТИВНЫЙ СТИЛЬ", + "Choose your first view": "Выберите первый вид", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Совет: кнопка GEV MIC в доке позволяет говорить с картой.", + "Aircraft, vessels and nearby intelligence": "Самолёты, суда и разведданные поблизости", + "Launches, spacecraft and orbital context": "Пуски, космические аппараты и орбитальный контекст", + "Begin with a clean globe": "Начните с чистого глобуса", + "MISSION CONTROL · FIRST LAUNCH": "ЦЕНТР УПРАВЛЕНИЯ · ПЕРВЫЙ ЗАПУСК", + "Don't show this again": "Больше не показывать", + "ESC to dismiss": "ESC — скрыть", + "LIVE CONTACTS": "КОНТАКТЫ ОНЛАЙН", + "EXPLORE MANUALLY": "ИЗУЧИТЬ ВРУЧНУЮ", + "Celestial ring — reveal the full globe": "Небесное кольцо — показать весь глобус", + "Celestial ring — available in Normal style": "Небесное кольцо — доступно в стиле «Обычный»", + "Detection overlay: {mode}": "Слой детекции: {mode}", + "Detection overlay: off": "Слой детекции: выкл.", + "SPARSE": "РЕДКО", + "BALANCED": "УМЕРЕННО", + "DETECT": "ДЕТЕКЦИЯ", + "AI AGENT": "ИИ-АГЕНТ", + "Voice model tier — applies next session": "Уровень голосовой модели — вступит в силу со следующей сессии", + "Estimated session cost": "Оценочная стоимость сессии", + "Voice control — activate to toggle voice; hold Space to speak": "Голосовое управление — активируйте, чтобы включить или выключить голос; удерживайте Space, чтобы говорить", + "VOICE STANDBY": "ГОЛОС В ОЖИДАНИИ", + "VOICE CONTROL": "ГОЛОСОВОЕ УПРАВЛЕНИЕ", + "Hold Space to speak · tap Space to activate focused controls": "Удерживайте Space, чтобы говорить · нажатие Space активирует элемент в фокусе", + "VOICE SYSTEM ERROR": "ОШИБКА ГОЛОСОВОЙ СИСТЕМЫ", + "DISMISS": "СКРЫТЬ", + "Check microphone permission and network access, then try again.": "Проверьте разрешение на микрофон и доступ к сети, затем попробуйте снова.", + "Release Space to send": "Отпустите Space, чтобы отправить", + "Hold Space to talk": "Удерживайте Space, чтобы говорить", + "Ask or command": "Спросите или дайте команду", + "WebRTC microphone support unavailable": "Поддержка микрофона WebRTC недоступна", + "Requesting microphone": "Запрос доступа к микрофону", + "Voice off": "Голос выключен", + "VOICE UNAVAILABLE": "ГОЛОС НЕДОСТУПЕН", + "VOICE ACTIVE": "ГОЛОС АКТИВЕН", + "Voice session could not be started.": "Не удалось запустить голосовую сессию.", + "CONNECTING": "ПОДКЛЮЧЕНИЕ", + "LISTENING": "СЛУШАЮ", + "EXECUTING": "ВЫПОЛНЕНИЕ", + "ERROR": "ОШИБКА", + "{tier} applies next session": "{tier} вступит в силу со следующей сессии", + "Session ended — cost cap {cost}": "Сессия завершена — лимит стоимости {cost}", + "Next session: {pendingId} — this session stays on {modelId}": "Следующая сессия: {pendingId} — текущая остаётся на {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Голосовая модель: {pendingId} — нажмите, чтобы переключиться на {target}; вступит в силу со следующей сессии", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Оценочная стоимость сессии на {modelId} — ответов: {responses}. Предупреждение при {warn}, завершение сессии при {cap}.", + "Radio did not start": "Не удалось запустить радио", + "Running command": "Выполнение команды" +} diff --git a/src/i18n/markupCoverage.test.mjs b/src/i18n/markupCoverage.test.mjs deleted file mode 100644 index 45c8743eb..000000000 --- a/src/i18n/markupCoverage.test.mjs +++ /dev/null @@ -1,128 +0,0 @@ -// Markup coverage gate (phase 2): every data-i18n* attribute in the static -// application markup must name a key that resolves in EVERY shipped catalog -// via the real catalog builder — a renamed or dropped catalog key can no -// longer strand a static extraction silently behind the key-itself fallback. -// index.html is now assembled from src/ui/templates/*.html by -// build/application-html.js before Vite processes it, so the gate expands the -// same templates the build serves and scans that assembled markup. Regex -// parsing is deliberate: no DOM dependency in unit tests. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { getCatalog } from './index.js'; -import { CATALOG_LOCALES, availableLocales } from './locale.js'; -import { expandApplicationHtml } from '../../build/application-html.js'; - -const INDEX_HTML = new URL('../../index.html', import.meta.url); -const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url); - -// The four spellings applyDocumentTranslations() knows about. Anything else -// (a typo like data-i18n-lable) must fail here, not silently never apply. -const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g; -const KNOWN_ATTRIBUTE_NAMES = new Set([ - 'data-i18n', - 'data-i18n-title', - 'data-i18n-aria-label', - 'data-i18n-placeholder', -]); - -const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8')); - -const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]); - -test('index.html carries static i18n references', () => { - assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?'); -}); - -test('index.html sources its body from the application templates', () => { - const raw = readFileSync(INDEX_HTML, 'utf8'); - assert.ok(raw.includes(' -
- LEVEL + LEVEL
-
- GROUND SPEED · KTS + GROUND SPEED · KTS
@@ -92,7 +92,7 @@
- ALTITUDE · FT + ALTITUDE · FT
@@ -112,36 +112,36 @@
- FIRST PERSON + FIRST PERSON AIRCRAFT - LIVE TRACK · COURSE ALIGNED + LIVE TRACK · COURSE ALIGNED
-
- - -
-
+
- GROUND SPEED + GROUND SPEED --- KTS
---°
- ALTITUDE + ALTITUDE ----- FT
@@ -153,26 +153,26 @@ --:--:--Z --° · --°
-