refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

-14
View File
@@ -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
+10 -23
View File
@@ -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 `<html lang>`/`<html dir>` 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.
-10
View File
@@ -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 ?? '',
+20 -116
View File
@@ -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 `<html lang>`/`<html
dir>` 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=<locale>` (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/<code>.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
+77 -181
View File
@@ -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 + <html lang>/<html dir>
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
<span data-i18n>Normal</span>
<button title="Pin visual presets" data-i18n-title>…</button>
```
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/<code>.json`, keyed by the English text:
- Markup surfaces: `<namespace>.<surface>.<element>` — e.g.
`shell.panels.dataLayers`, `cockpit.readout.altitude`.
- Runtime flows/states: `<namespace>.<flow>.<state>` — 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/<code>/` and translate all four files
(`<code>` 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 `<CODE>_NAMESPACES` array, then one entry in the `CATALOGS`
map. Registration is append-only; nothing else in the file changes.
3. **Shipping.** Append `'<code>'` 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.<code>.ariaLabel': 'Switch to <Language>'` 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='<code>'] .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/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
## Adding a language
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
5. 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 | ПРЕД. / СЛЕД. |
+1
View File
@@ -35,6 +35,7 @@
"doctor": "node scripts/setup-doctor.mjs",
"format": "node scripts/format.mjs --write",
"format:check": "node scripts/format.mjs --check",
"i18n": "node scripts/i18n.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"dev": "vite",
+257
View File
@@ -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<string, {plural: boolean, one?: string, where: string}>}
*/
export function extractMessages() {
const messages = new Map();
const add = (text, where, plural = null) => {
if (!text) return;
const known = messages.get(text);
if (known && Boolean(known.plural) !== Boolean(plural)) {
throw new Error(
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
);
}
if (!known)
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
};
const sources = 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));
}
+31 -124
View File
@@ -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": []
},
-2
View File
@@ -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,
+2 -2
View File
@@ -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 };
+10 -7
View File
@@ -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,
+1 -1
View File
@@ -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';
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -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'),
};
}
+13 -11
View File
@@ -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'),
}),
});
+20 -20
View File
@@ -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'),
});
/**
+22 -12
View File
@@ -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({
+4 -4
View File
@@ -202,15 +202,15 @@ export class IntelHUD {
<div class="hud-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-summary-wrap">
<div class="hud-summary-label">${t('cockpit.hud.summaryLabel')}</div>
<div class="hud-summary" id="hud-summary">${t('cockpit.hud.summaryAwaiting')}</div>
<div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('cockpit.hud.recLabel')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div>
<div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
</div>
<div class="hud-bracket">┐</div>
@@ -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';
+43 -184
View File
@@ -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.<category> ?? 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);
});
+175 -427
View File
@@ -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 <html lang>.
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'],
]);
});
+123 -225
View File
@@ -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/<locale>/<ns>.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/<code>.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<string, string|object>} 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<string, string|object>|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<string, object>} 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.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
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 {
+29 -256
View File
@@ -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:<name>: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/<code>.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=<locale> → 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: `<html lang>` and `<html dir>`
* 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 <html lang> and <html dir>. */
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
const root = doc?.documentElement;
if (!root) return false;
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
root.lang = normalized;
root.dir = (
LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE]
).dir;
root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
return true;
}
-562
View File
@@ -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 <i>, 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 <br> 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',
};
-517
View File
@@ -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',
};
-213
View File
@@ -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 <strong>/<small> 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}.',
};
-82
View File
@@ -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.<code>.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',
};
+870
View File
@@ -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": "Выполнение команды"
}
-128
View File
@@ -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('<!-- gev:template '),
'index.html must keep the gev:template markers the build expands');
assert.equal((raw.match(/data-i18n/g) || []).length, 0,
'data-i18n markup belongs in src/ui/templates/*.html, not the marker shell');
});
test('every data-i18n* attribute value resolves in every shipped catalog', () => {
const catalogs = Object.fromEntries(CATALOG_LOCALES.map((locale) => [locale, getCatalog(locale)]));
for (const locale of CATALOG_LOCALES) {
assert.ok(catalogs[locale], `catalog builder produced the ${locale} locale`);
}
const missing = references.filter((key) => (
CATALOG_LOCALES.some((locale) => !(key in catalogs[locale]))
));
assert.deepEqual(
[...new Set(missing)],
[],
'static markup references keys absent from a catalog (translation would fall back or render the key)',
);
});
test('no unknown data-i18n* attribute spelling sneaks past the application pass', () => {
const stray = [...html.matchAll(/(data-i18n[a-z-]*)="/g)]
.map((match) => match[1])
.filter((name) => !KNOWN_ATTRIBUTE_NAMES.has(name));
assert.deepEqual(
[...new Set(stray)],
[],
'applyDocumentTranslations() only applies the four known attributes; a fifth spelling is dead markup',
);
});
test('attribute values are well-formed catalog keys', () => {
for (const key of references) {
assert.match(key, /^(shell|cockpit|layers|setup)\.[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)*$/, `malformed key: ${key}`);
}
});
test('the dock language switch keeps a static group container with runtime-rendered buttons', () => {
const selector = html.match(/<div class="dock-locale-switch"[\s\S]*?<\/div>/);
assert.ok(selector, 'the locale switch group is missing from the control-panel tray');
assert.match(selector[0], /data-i18n-aria-label="shell\.locale\.groupAriaLabel"/);
// Buttons are no longer static: ui.js _initLocaleSelector() renders one per
// locale in the configured pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE).
assert.doesNotMatch(
selector[0],
/dock-locale-btn/,
'static selector buttons must move to the runtime renderer so the configured pair shapes the switch',
);
// Markup-only contract: no listener may attach from markup.
assert.doesNotMatch(selector[0], / onclick| onsubmit| javascript:/i);
});
test('every offered locale has a shell.locale.<code>.ariaLabel in every catalog; the replaced family stays deleted', () => {
// The runtime renderer reads shell.locale.<code>.ariaLabel for every locale
// availableLocales() can yield under any configuration — i.e. every shipped
// catalog locale — and it must exist in every catalog.
for (const catalogLocale of CATALOG_LOCALES) {
const catalog = getCatalog(catalogLocale);
for (const code of CATALOG_LOCALES) {
assert.ok(
catalog[`shell.locale.${code}.ariaLabel`],
`${catalogLocale} catalog is missing shell.locale.${code}.ariaLabel for the runtime selector`,
);
}
assert.equal(catalog['shell.locale.english.ariaLabel'], undefined,
'the replaced phase-2 english.ariaLabel key stays deleted');
assert.equal(catalog['shell.locale.spanish.ariaLabel'], undefined,
'the replaced phase-2 spanish.ariaLabel key stays deleted');
}
// Default configuration pins the pair the shipped build offers: en-only
// while English is the only shipped catalog (the selector renders a single
// EN button; each secondary-locale PR widens this pin).
assert.deepEqual([...availableLocales()], ['en']);
});
test('ui.js _initLocaleSelector renders the configured pair (runtime contract)', () => {
const source = readFileSync(UI_JS, 'utf8');
const start = source.indexOf('_initLocaleSelector() {');
assert.ok(start >= 0, '_initLocaleSelector method not found in src/ui.js');
const body = source.slice(start, source.indexOf('\n }\n', start));
assert.ok(body.length > 0, '_initLocaleSelector body not extracted');
assert.match(body, /availableLocales\(\)/, 'iterates the resolved pair, not a hardcoded EN|ES');
assert.match(body, /shell\.locale\.\$\{locale\}\.ariaLabel/, 'aria-labels come from the locale key family');
assert.match(body, /toUpperCase\(\)/, 'labels are uppercase locale codes');
assert.match(body, /aria-pressed/, 'pressed state is synced');
assert.match(body, /persistLocaleAndReload/, 'clicks persist and reload');
assert.match(body, /dock-locale-switch/, 'renders into the static group container');
});
-83
View File
@@ -1,83 +0,0 @@
// Repair-pass anchors (extraction correctness).
//
// en behavior must stay byte-identical: the values added for the director
// status corpus, the keySetup dev surface, and the map-source wiring are
// pinned here to the exact literals the code rendered before extraction.
// Cross-module anchors (keySetupCore registry) catch catalog/registry drift
// in either direction. The locale-scoped CCTV wrap rules and per-locale value
// pins live in their locale PRs (es first); this file pins the English
// contract plus the pixel-identical English layout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getCatalog } from './index.js';
import { KEY_SETUP_KEYS, keySetupRequirement } from '../keySetupCore.mjs';
const en = getCatalog('en');
test('director status corpus keeps the pre-extraction English literals byte-identical', () => {
const expected = {
'setup.scenes.status.captureCameraNotReady': 'Cannot capture shot: camera not ready',
'setup.scenes.status.shotTitleDefault': 'Shot {n}',
'setup.scenes.status.captured': 'Captured: {scene} / {shot}',
'setup.scenes.status.selectShotFirst': 'Select a shot first',
'setup.scenes.status.updated': 'Updated: {scene} / {shot}',
'setup.scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?',
'setup.scenes.status.loaded': 'Loaded: {scene} / {shot}',
'setup.scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first',
'setup.scenes.status.noShotsToRun': 'No shots to run',
'setup.scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}',
'setup.scenes.status.runComplete': 'Scene run complete',
'setup.scenes.status.runError': 'Error: {message}',
'setup.scenes.status.contextExitFailed': 'Could not exit {mode} — scene layers may be refused',
};
for (const [key, value] of Object.entries(expected)) {
assert.equal(en[key], value, `${key} drifted from the removed English literal`);
}
});
test('setup.keySetup.unlocks.* en values equal the keySetupCore registry copy', () => {
for (const entry of KEY_SETUP_KEYS) {
const key = `setup.keySetup.unlocks.${entry.id}`;
assert.equal(en[key], entry.unlocks, `${key} drifted from KEY_SETUP_KEYS unlocks`);
}
assert.equal(en['setup.keySetup.requirement'], 'Needs {envVars} — add it in Provider Settings');
assert.equal(
keySetupRequirement('cesium-ion'),
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
'en requirement sentence must stay byte-identical (pinned by keySetupCore.test.mjs)',
);
});
test('map-source keys match the static markup pinned by mapStackChips.test.mjs', () => {
assert.equal(en['cockpit.presets.mapSourceLabel'], 'MAP SOURCE');
assert.equal(en['cockpit.presets.mapSourceChipsAriaLabel'], 'Map source');
});
test('the seven redundant layers.radio transport seeds stay deleted', () => {
const deleted = [
'layers.radio.pause',
'layers.radio.resume',
'layers.radio.playNearestAria',
'layers.radio.pauseSelectedAria',
'layers.radio.pauseNearestAria',
'layers.radio.resumeSelectedAria',
'layers.radio.resumeNearestAria',
];
for (const key of deleted) {
assert.equal(en[key], undefined, `${key} was removed by the repair pass`);
}
});
test('the English CCTV control rows stay unwrapped: no locale-scoped wrap rule ships ahead of its locale', () => {
// Locale wrap rules (html[lang='…'] .cctv-controls { flex-wrap: wrap }) are
// added one per locale PR, each with a measured-width justification — never
// speculatively. Until then the base rule must stay unwrapped so English
// pixels are unchanged, and no html[lang] override may exist.
const css = readFileSync(new URL('../../src/ui/styles/cctv.css', import.meta.url), 'utf8');
const baseStart = css.indexOf('.cctv-controls {');
const baseBody = css.slice(baseStart, css.indexOf('}', baseStart));
assert.ok(!baseBody.includes('flex-wrap'), 'base .cctv-controls must stay unwrapped (EN pixel-identical)');
assert.ok(!/html\[lang=/i.test(css), 'no locale-scoped CCTV rule may ship before its locale does');
});
+24 -18
View File
@@ -20,7 +20,7 @@ import {
* the chip and the dialog are removed outright.
*/
import { t, hasMessage } from './i18n/index.js';
import { t, msg, tn } from './i18n/index.js';
/**
* Chip label — pure, exported for tests. The exact English values are pinned
@@ -32,8 +32,12 @@ import { t, hasMessage } from './i18n/index.js';
export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0
? t('setup.keySetup.chipWaiting', { count: missing })
: t('setup.keySetup.chipReady');
? tn(
missing,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
)
: t('POWERED UP');
}
/**
@@ -186,10 +190,7 @@ function buildRow(documentRef, key) {
const unlocks = documentRef.createElement('p');
unlocks.className = 'key-setup-unlocks';
// The registry `unlocks` text localizes per registry id; an id missing from
// the catalog falls back to the server-provided English copy.
const unlocksKey = `setup.keySetup.unlocks.${key.id}`;
unlocks.textContent = hasMessage(unlocksKey) ? t(unlocksKey) : key.unlocks;
unlocks.textContent = t(key.unlocks);
row.append(head, unlocks);
if (!external) {
@@ -214,8 +215,10 @@ function buildRow(documentRef, key) {
remove.type = 'button';
remove.className = 'key-setup-remove';
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
remove.textContent = t('setup.keySetup.row.remove');
remove.title = t('setup.keySetup.row.removeTitle', { title: key.title });
remove.textContent = t('REMOVE');
remove.title = t("Remove {title} from this app's saved keys", {
title: key.title,
});
fields.append(remove);
}
row.append(fields);
@@ -380,8 +383,8 @@ export async function initKeySetup({
const storeLabel = () =>
status?.store === 'pinokio-environment'
? t('setup.keySetup.store.pinokio')
: t('setup.keySetup.store.env');
? t('your app configuration')
: t('your local .env');
// doneMessageKey names the whole saved/removed sentence; the storage
// destination is interpolated so the {store} phrase stays one spelling.
@@ -392,7 +395,7 @@ export async function initKeySetup({
)?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
say(t('setup.keySetup.status.saving'));
say(t('Saving…'));
try {
const response = await doFetch('/api/setup/keys', {
method: 'POST',
@@ -406,7 +409,7 @@ export async function initKeySetup({
// payload.error is a server-authored message (API value) — shown as-is.
say(
payload.error ||
t('setup.keySetup.status.saveFailed', { status: response.status }),
t('Save failed ({status}).', { status: response.status }),
);
return;
}
@@ -440,7 +443,7 @@ export async function initKeySetup({
say(t(doneMessageKey, { store: storeLabel() }));
} catch (error) {
say(
t('setup.keySetup.status.saveFailedDetail', {
t('Save failed: {detail}', {
detail: error?.message || error,
}),
);
@@ -460,10 +463,13 @@ export async function initKeySetup({
})),
);
if (!Object.keys(updates).length) {
say(t('setup.keySetup.status.pasteFirst'));
say(t('Paste at least one key first.'));
return;
}
await submitUpdates(updates, 'setup.keySetup.status.saved');
await submitUpdates(
updates,
msg('Saved to {store}. Restarting — this page reloads itself.'),
);
};
chip.addEventListener('click', openDialog);
@@ -579,11 +585,11 @@ export async function initKeySetup({
// a deliberate two-step the lure cannot pre-satisfy.
const ok =
typeof globalThis.confirm !== 'function' ||
globalThis.confirm(t('setup.keySetup.confirm.remove'));
globalThis.confirm(t('Remove this key from your saved configuration?'));
if (!ok) return;
void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])),
'setup.keySetup.status.removed',
msg('Removed from {store}. Restarting — this page reloads itself.'),
);
});
+17 -10
View File
@@ -32,11 +32,15 @@ export const KEY_SETUP_APPEND_HEADER =
* bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count.
*/
// Marks `unlocks` copy for i18n extraction; Provider Settings translates it
// with t(). Kept local so the server can import this module without i18n.
const msg = (text) => text;
export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS',
unlocks: 'The photorealistic 3D planet + place search',
unlocks: msg('The photorealistic 3D planet + place search'),
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered',
@@ -45,7 +49,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps-server',
title: 'GOOGLE MAPS — SERVER',
unlocks: 'Places context + Street View fallback; optional separate key',
unlocks: msg(
'Places context + Street View fallback; optional separate key',
),
getUrl:
'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
@@ -55,7 +61,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'openai',
title: 'OPENAI',
unlocks: 'Voice control — API key or ChatGPT OAuth',
unlocks: msg('Voice control — API key or ChatGPT OAuth'),
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
@@ -63,7 +69,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'aisstream',
title: 'AISSTREAM',
unlocks: 'Live ships, worldwide',
unlocks: msg('Live ships, worldwide'),
getUrl: 'https://aisstream.io',
envVars: Object.freeze(['AISSTREAM_API_KEY']),
tier: 'free',
@@ -71,7 +77,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'firms',
title: 'NASA FIRMS',
unlocks: 'Live active-fire detections',
unlocks: msg('Live active-fire detections'),
getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free',
@@ -79,7 +85,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'tomtom',
title: 'TOMTOM',
unlocks: 'Real live traffic (keyless runs a simulation)',
unlocks: msg('Real live traffic (keyless runs a simulation)'),
getUrl: 'https://my.tomtom.com/keys',
envVars: Object.freeze(['TOMTOM_API_KEY']),
tier: 'free',
@@ -87,7 +93,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'cesium-ion',
title: 'CESIUM ION',
unlocks: 'Bing imagery map stacks + world terrain',
unlocks: msg('Bing imagery map stacks + world terrain'),
getUrl: 'https://ion.cesium.com/tokens',
envVars: Object.freeze(['CESIUM_ION_TOKEN']),
tier: 'free',
@@ -96,7 +102,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'opensky',
title: 'OPENSKY',
unlocks: 'More flight-polling credits (anonymous works without)',
unlocks: msg('More flight-polling credits (anonymous works without)'),
getUrl: 'https://opensky-network.org',
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free',
@@ -104,8 +110,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY',
unlocks:
unlocks: msg(
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
),
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
@@ -114,7 +121,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY',
unlocks: 'Higher space-missions request allowance',
unlocks: msg('Higher space-missions request allowance'),
getUrl: 'https://thespacedevs.com',
envVars: Object.freeze(['LL2_API_TOKEN']),
tier: 'free',
+10 -11
View File
@@ -57,8 +57,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong>
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span>
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
@@ -79,11 +79,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="${t('layers.awareness.unavailableAria')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel =
label === '—' ? t('layers.awareness.unavailableAria') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('layers.awareness.focusAria', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -104,10 +103,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="${t('layers.awareness.controlsAria')}">
<button type="button" data-awareness-action="previous" title="${t('layers.awareness.previousTitle')}"${canPrevious ? '' : ' disabled'}>${t('layers.awareness.previous')}</button>
<button type="button" data-awareness-action="focus">${t('layers.awareness.focus')}</button>
<button type="button" data-awareness-action="next" title="${t('layers.awareness.nextTitle')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('layers.awareness.next')}</button>
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
}
@@ -189,7 +188,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('layers.awareness.note')}</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
+4 -4
View File
@@ -138,7 +138,7 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = t('layers.awareness.hdg', {
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
@@ -224,12 +224,12 @@ export function createRendering({
)
: null;
const bearingText = Number.isFinite(bearing)
? t('layers.awareness.brg', {
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('layers.awareness.brgUnknown');
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · ${t('layers.awareness.crs', {
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
+7 -8
View File
@@ -19,10 +19,9 @@ const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY')
return t('layers.awareness.coverage.viewport');
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('layers.awareness.coverage.within', { km: within[1] });
if (within) return t('WITHIN {km} KM', { km: within[1] });
return label;
}
@@ -94,7 +93,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [
{
id: 'flights',
label: t('layers.awareness.cohort.flights'),
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
@@ -113,7 +112,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military',
label: t('layers.awareness.cohort.military'),
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
@@ -132,7 +131,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'ais-live-vessels',
label: t('layers.awareness.cohort.vessels'),
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
@@ -147,7 +146,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: t('layers.awareness.cohort.installations'),
label: t('Mapped installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
@@ -159,7 +158,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
),
coverage:
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('layers.awareness.coverage.viewport'),
t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
+2 -2
View File
@@ -68,10 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? t('layers.bike.loadingSyncing', {
? t('syncing {count} city feeds...', {
count: layerState._activeCityIds.size,
})
: t('layers.bike.loadingScanning');
: t('scanning nearby systems...');
}
if (layerState._error) stats.error = layerState._error;
return stats;
+10 -9
View File
@@ -24,8 +24,8 @@ export function createSelection({
const stationLabel =
stationName ||
(record?.stationId
? t('layers.bike.stationWithId', { id: record.stationId })
: t('layers.bike.stationFallback'));
? t('Station {id}', { id: record.stationId })
: t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -36,17 +36,18 @@ export function createSelection({
const lines = [
stationLabel,
t('layers.bike.card.capacity', { bikes, docks, capacity }),
t('🚲 {bikes} avail · {docks} docks · {capacity} cap', {
bikes,
docks,
capacity,
}),
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false)
abnormal.push(t('layers.bike.warning.notInstalled'));
if (record?.isRenting === false)
abnormal.push(t('layers.bike.warning.notRenting'));
if (record?.isReturning === false)
abnormal.push(t('layers.bike.warning.notReturning'));
if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
+2 -2
View File
@@ -48,6 +48,6 @@ export function headingHudToken(camera) {
// The token copy lives in the layers catalog (en is byte-identical to the
// literals these replace); presentation stays here, translation at read.
return isHeadingEstimated(camera)
? t('layers.cctv.summary.hdgEstimated', { value: hdg })
: t('layers.cctv.summary.hdg', { value: hdg });
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
}
+18 -19
View File
@@ -20,10 +20,13 @@ export function createPresentation({
const active = parts.selection.getActiveRecord();
if (!active) {
return layerState._records.length
? t('layers.cctv.summary.standingBy', {
count: layerState._records.length,
})
: t('layers.cctv.summary.empty');
? t(
'{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE',
{
count: layerState._records.length,
},
)
: t('No cameras available in catalog.');
}
const area = parts.model.sectorAreaKm2(
@@ -37,37 +40,33 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [
t('layers.cctv.summary.city', {
t('{city} CCTV', {
city: active.camera.city.toUpperCase(),
}),
`${active.camera.name.toUpperCase()}`,
// A synthetic bearing (headingConfidence 'low', no human calibration)
// is tagged so a hashed guess never reads as a surveyed facing (#639).
headingHudToken(active.camera),
t('layers.cctv.summary.fov', {
t('FOV {value}°', {
value: Math.round(active.camera.fovDeg),
}),
t('layers.cctv.summary.coverage', { value: area.toFixed(2) }),
t('COVERAGE {value}km²', { value: area.toFixed(2) }),
overlapCount > 0
? t('layers.cctv.summary.overlap', { count: overlapCount })
: t('layers.cctv.summary.isolated'),
layerState._showProjection
? t('layers.cctv.summary.projMonitor')
: t('layers.cctv.summary.projOff'),
layerState._coverageMode === 'viewshed'
? t('layers.cctv.summary.viewshed')
: null,
t('layers.cctv.summary.cal', {
? t('OVERLAP {count} cams', { count: overlapCount })
: t('ISOLATED VIEW'),
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
t('CAL {value}', {
value: calBadge.replace('-', ' ').toUpperCase(),
}),
health?.sourceKind
? t('layers.cctv.summary.src', {
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('layers.cctv.summary.src', {
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }),
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
]
.filter(Boolean)
.join(' · ');
+1 -1
View File
@@ -116,7 +116,7 @@ export function createCards({
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: t('layers.firms.card.focusAria', {
accessibilityLabel: t('Focus fire detection {title}, {details}', {
title: card.title,
details: card.details.join(', '),
}),
+13 -21
View File
@@ -331,19 +331,17 @@ export function createModel({
function buildSelectedFireCard(fire, nowMs) {
const meta = [
t('layers.firms.card.confSuffix', {
t('{value} conf', {
value: confidenceBucket(fire.confidence),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(t('layers.firms.card.ageSuffix', { value: age }));
if (age) meta.push(t('{value} ago', { value: age }));
}
const sat = satelliteShortName(fire.satellite);
meta.push(
sat
? `${fire.sensor || 'VIIRS'} ${sat}`
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || t('sensor n/a'),
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -353,11 +351,11 @@ export function createModel({
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('layers.firms.card.fire', { frp: formatFrp(fire.frp) }),
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) +
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''),
(fire.night ? ` · ${t('NIGHT')}` : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
@@ -390,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: t('layers.firms.card.ambient', { frp: formatFrp(fire.frp) }),
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -408,16 +406,11 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun =
cell.count === 1
? t('layers.firms.card.fireNoun')
: t('layers.firms.card.firesNoun');
const parts = [
t('layers.firms.card.maxFrp', { value: formatFrp(cell.maxFrp) }),
];
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(t('layers.firms.card.newAge', { value: age }));
if (age) parts.push(t('new {value}', { value: age }));
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
@@ -503,12 +496,11 @@ export function createModel({
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0)
return t('layers.meta.justNow');
if (!Number.isFinite(deltaMs) || deltaMs < 0) return t('just now');
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return t('layers.firms.underMinuteAgo');
if (minutes < 90) return t('layers.meta.minutesAgo', { count: minutes });
return t('layers.meta.hoursAgo', { count: Math.round(minutes / 60) });
if (minutes < 1) return t('<1m ago');
if (minutes < 90) return t('{count}m ago', { count: minutes });
return t('{count}h ago', { count: Math.round(minutes / 60) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+7 -7
View File
@@ -60,24 +60,24 @@ export function createQueries({
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? t('layers.firms.staleCached', {
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
})
: t('layers.status.stale');
: t('STALE');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? t('layers.meta.refreshing')
: t('layers.meta.loading');
? t('refreshing...')
: t('loading...');
} else if (layerState._keyRequired) {
loadingLabel = t('layers.firms.keyRequired');
loadingLabel = t('KEY REQUIRED');
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = t('layers.firms.liveUpdated', {
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
@@ -93,7 +93,7 @@ export function createQueries({
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? t('layers.firms.keyRequired')
? t('KEY REQUIRED')
: layerState._stale
? staleText
: layerState._error,
+1 -1
View File
@@ -756,7 +756,7 @@ export function createTracking({
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('layers.status.stale')
? t('STALE')
: '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
+18 -17
View File
@@ -5,7 +5,7 @@ import {
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t } from '../../i18n/index.js';
import { t, msg } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so
@@ -13,19 +13,20 @@ import { t } from '../../i18n/index.js';
// boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': 'layers.installations.feedback.retrying',
'Fetching mapped sites…': 'layers.installations.feedback.fetching',
'Zoom in to search mapped installations':
'layers.installations.feedback.zoomIn',
'Showing cached mapped sites': 'layers.installations.feedback.cached',
'Mapped sites not loaded': 'layers.installations.feedback.notLoaded',
'Mapped sites loaded': 'layers.installations.feedback.loaded',
'Overpass rate-limited': 'layers.installations.feedback.reason.rateLimited',
'Overpass timed out': 'layers.installations.feedback.reason.timeout',
'Overpass could not complete the query':
'layers.installations.feedback.reason.queryFailed',
'Overpass temporarily unavailable':
'layers.installations.feedback.reason.unavailable',
'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': msg(
'Zoom in to search mapped installations',
),
'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass timed out': msg('Overpass timed out'),
'Overpass could not complete the query': msg(
'Overpass could not complete the query',
),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
@@ -38,7 +39,7 @@ function localizeInstallationFeedback(message) {
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('layers.installations.feedback.retryIn', {
return t('{reason} — retrying in {seconds}s', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
@@ -46,7 +47,7 @@ function localizeInstallationFeedback(message) {
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('layers.installations.feedback.retryPending', {
return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
}
@@ -280,7 +281,7 @@ export function createControls({ state: layerState, services, parts, source }) {
}),
),
loadingLabel: layerState.loading
? t('layers.installations.loading')
? t('loading mapped installation context')
: '',
coverage: layerState.coverage,
coverageLabel:
+1 -1
View File
@@ -271,7 +271,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
title: record.name || t('layers.installations.fallbackTitle'),
title: record.name || t('MAPPED INSTALLATION'),
details: [
String(record.class || 'installation')
.replaceAll('_', ' ')
+13 -14
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { t, msg } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -120,10 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const details = selected
? [
siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite'),
: t('LAUNCH SITE'),
]
: [];
return {
@@ -589,38 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
const siteCallout = siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite');
: t('LAUNCH SITE');
let title = mission;
let detail = siteCallout;
if (mode === 'countdown') {
title = t('layers.missions.replay.countdown', {
title = t('T−{time} · {mission}', {
time: String(state.countdownSeconds).padStart(2, '0'),
mission,
});
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
} else if (mode === 'ascent') {
title =
state.elapsedSinceStart < 1
? t('layers.missions.replay.liftoff', { mission })
? t('LIFTOFF · {mission}', { mission })
: t(
launch.trajectory.length > 1
? 'layers.missions.replay.ascentReplay'
: 'layers.missions.replay.ascentEstimate',
? msg('ASCENT REPLAY · {mission}')
: msg('ASCENT ESTIMATE · {mission}'),
{ mission },
);
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') {
title = t('layers.missions.replay.recovery', { mission });
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') {
title = t('layers.missions.replay.orbit', { mission });
title = t('ORBIT REPLAY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused)
title = t('layers.missions.replay.pausedPrefix', { title });
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
+20 -23
View File
@@ -220,12 +220,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.unavailable'))}</td></tr>`;
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
@@ -233,7 +233,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
).innerHTML = missionTableRows(
payloadRows,
3,
t('layers.missions.rows.classified'),
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
@@ -241,21 +241,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: t('layers.missions.rows.unavailable'));
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? t('layers.missions.rows.kmDownrange', {
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('layers.missions.rows.positionUnavailable');
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? t('layers.missions.rows.flightNumber', {
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? t('layers.missions.rows.reused') : null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
@@ -263,7 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, t('layers.missions.rows.noStageData'));
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
@@ -291,7 +291,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('layers.missions.roster.count', {
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
@@ -301,7 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
@@ -314,15 +314,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
list.innerHTML = entries
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date =
launch.launchTime?.slice(0, 10) ||
t('layers.missions.roster.dateUnavailable');
const provider =
launch.provider || t('layers.missions.roster.unspecifiedOperator');
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('layers.missions.roster.selectAria', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
@@ -379,7 +376,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-distance]',
Number.isFinite(altitudeM)
? t('layers.missions.telemetry.km', {
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
@@ -392,7 +389,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-speed]',
Number.isFinite(speedMps)
? t('layers.missions.telemetry.kmPerSecond', {
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -473,16 +470,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('layers.missions.panel.selectedHeader'),
t('SELECTED SPACE MISSION'),
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('layers.missions.panel.selectedHeader')}</span><button type="button" data-mission-close title="${t('layers.missions.panel.showAllTitle')}" aria-label="${t('layers.missions.panel.deselectAria')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('layers.missions.panel.mission')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('layers.missions.panel.status')}<b data-mission-status></b></span><span data-mission-field>${t('layers.missions.panel.launchSite')}<b data-mission-site></b></span><span data-mission-field>${t('layers.missions.panel.launchTime')}<b data-mission-time></b></span><span data-mission-field>${t('layers.missions.panel.orbit')}<b data-mission-orbit></b></span><span>${t('layers.missions.panel.ascentPath')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('layers.missions.panel.distance')}<b data-mission-distance></b></span><span data-mission-field>${t('layers.missions.panel.speed')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('layers.missions.panel.payload')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colName')}</th><th>${t('layers.missions.panel.colType')}</th><th>${t('layers.missions.panel.colDestination')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('layers.missions.panel.stagesSection')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colStage')}</th><th>${t('layers.missions.panel.colStatus')}</th><th>${t('layers.missions.panel.colFinalPosition')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('layers.missions.replay.speedLabel')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('layers.missions.replay.speedAria')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('layers.missions.panel.focus')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('layers.missions.replay.start')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('layers.missions.panel.prevTitle')}"><span aria-hidden="true">‹</span> ${t('layers.missions.panel.prev')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('layers.missions.panel.nextTitle')}">${t('layers.missions.panel.next')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('layers.missions.panel.showAll')}</button>`;
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="${t('layers.missions.replay.pause')}" aria-label="${t('layers.missions.replay.pause')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></button>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
+1 -1
View File
@@ -522,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
return {
lat: Cesium.Math.toDegrees(lat),
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
accuracy: t('layers.missions.estDownrange'),
accuracy: t('EST. DOWNRANGE'),
};
}
+5 -5
View File
@@ -77,14 +77,14 @@ export function createPolicyHelpers({
: 0;
return {
orbit: orbitName
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
: null,
ascent:
suppliedTrajectoryPoints > 1
? t('layers.missions.rows.suppliedTrajectory')
? t('SUPPLIED TRAJECTORY POINTS')
: replayAvailable
? t('layers.missions.rows.reconstructedEstimate')
: t('layers.missions.rows.unavailable'),
? t('RECONSTRUCTED ESTIMATE')
: t('UNAVAILABLE'),
replayAvailable: Boolean(replayAvailable),
};
}
@@ -198,7 +198,7 @@ export function createPolicyHelpers({
function formatMissionEventTime(launchTime) {
const date = new Date(launchTime);
if (!launchTime || !Number.isFinite(date.getTime()))
return t('layers.missions.rows.unavailable');
return t('UNAVAILABLE');
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
}
+11 -13
View File
@@ -201,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable;
button.textContent = t('layers.missions.replay.start');
button.textContent = t('REPLAY ASCENT');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = t('layers.missions.replay.startTitle');
button.title = t('Replay the estimated ascent with a following camera');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
@@ -219,13 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute(
'aria-label',
layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause'),
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
);
toggleButton.title = layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause');
? t('Resume replay')
: t('Pause replay');
}
}
}
@@ -236,19 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? t('layers.missions.replay.phaseCountdown', {
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
})
: state.preCountdownActive
? t('layers.missions.replay.phasePreparing')
? t('Preparing launch site')
: state.elapsedSinceStart < 1
? t('layers.missions.replay.phaseLiftoff')
? t('Liftoff')
: state.ascending
? t('layers.missions.replay.phaseAscent')
: t('layers.missions.replay.phaseOrbit');
? t('Ascent replay')
: t('Orbit replay');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function createQueries({
*/
function _formatAltitude(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return t('layers.readout.altUnknown');
if (!Number.isFinite(altitudeFt)) return t('Alt unknown');
return `${Math.round(altitudeFt)} ft`;
}
+4 -6
View File
@@ -220,7 +220,7 @@ export function createTracking({
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ` · ${t('layers.status.stale')}`
? ` · ${t('STALE')}`
: '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
@@ -230,14 +230,12 @@ export function createTracking({
// what the Easter egg replaces the real type with.
const type = tr3bTypeLabel(
icao24,
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'),
parts.queries._toCleanText(info?.type) || t('Type unknown'),
);
const registration =
parts.queries._toCleanText(info?.registration) ||
t('layers.readout.regUnknown');
parts.queries._toCleanText(info?.registration) || t('Reg unknown');
const operator =
parts.queries._toCleanText(info?.operator) ||
t('layers.readout.operatorUnknown');
parts.queries._toCleanText(info?.operator) || t('Operator unknown');
const altitude = parts.queries._formatAltitude(info?.altitudeFt);
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
+9 -9
View File
@@ -70,20 +70,20 @@ export function createCategories({
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ id: 'all', label: t('layers.radio.filterAll') },
{ id: 'news', label: t('layers.radio.category.news') },
{ id: 'talk', label: t('layers.radio.category.talk') },
{ id: 'weather', label: t('layers.radio.category.weather') },
{ id: 'public-safety', label: t('layers.radio.category.publicSafety') },
{ id: 'all', label: t('All') },
{ id: 'news', label: t('News') },
{ id: 'talk', label: t('Talk') },
{ id: 'weather', label: t('Weather / Emergency') },
{ id: 'public-safety', label: t('Public Safety') },
{
id: 'aviation-marine',
label: t('layers.radio.category.aviationMarine'),
label: t('Aviation / Marine'),
},
{
id: 'traffic-transit',
label: t('layers.radio.category.trafficTransit'),
label: t('Traffic / Transit'),
},
{ id: 'music', label: t('layers.radio.category.music') },
{ id: 'music', label: t('Music') },
];
for (const [genre, label] of MUSIC_GENRES) {
@@ -92,7 +92,7 @@ export function createCategories({
categories.push({ id, label });
}
}
categories.push({ id: 'other', label: t('layers.radio.category.other') });
categories.push({ id: 'other', label: t('Other') });
return categories.map((category) => ({
...category,
color: parts.model.radioCategoryColor(category.id),
+13 -8
View File
@@ -494,22 +494,27 @@ export function createControls({ state: layerState, services, parts, source }) {
const active =
layerState._params.catalog === 'dense' &&
layerState._denseStatus === 'ready';
let title = t('layers.satellites.denseTitleAdd');
if (loading) title = t('layers.satellites.denseTitleLoading');
let title = t(
'Add the full Starlink broadband shell (thousands of extra points)',
);
if (loading) title = t('Loading the Starlink shell…');
else if (failed)
title = t('layers.satellites.denseTitleFailed', {
detail: layerState._denseError || t('layers.satellites.loadFailed'),
title = t('Starlink {detail} — click to retry', {
detail: layerState._denseError || t('load failed'),
});
else if (active) title = t('layers.satellites.denseTitleActive');
else if (active)
title = t(
'Showing the full Starlink shell — click for the core catalog only',
);
return {
chips: [
{
id: 'catalog',
label: loading
? t('layers.satellites.denseChipLoading')
? t('DENSE ···')
: failed
? t('layers.satellites.denseChipFailed')
: t('layers.satellites.denseChip'),
? t('DENSE ✕')
: t('DENSE'),
active,
busy: loading,
disabled: loading,
+1 -1
View File
@@ -12,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
state._loading = true;
state._error = null;
state._loadingLabel = t('layers.meta.loading');
state._loadingLabel = t('loading...');
// Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this
// one is still settling. The stale load must bail after every await and
+24 -16
View File
@@ -261,12 +261,12 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource })
? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0
? t('layers.traffic.liveRoadsFlowHidden', {
? t('LIVE · Roads: {source} · Roads without flow hidden', {
source: roadSource,
})
: t('layers.traffic.liveRoadsNoFlowInView', {
: t('LIVE · Roads: {source} · No flow roads in view', {
source: roadSource,
}),
};
@@ -276,13 +276,13 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource })
? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0
? t('layers.traffic.roadsFlowLive', {
? t('LIVE · Roads: {source} · Flow {percent}%', {
source: roadSource,
percent: coveragePct,
})
: t('layers.traffic.roadsFlowSimulated', {
: t('SIMULATED · Roads: {source} · Flow {percent}%', {
source: roadSource,
percent: coveragePct,
}),
@@ -293,16 +293,24 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: fetching
? t('layers.traffic.loadingSyncing')
? t(
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
)
: coveragePct > 0
? coveragePct < 100
? t('layers.traffic.liveCoverageUnmatched', {
percent: coveragePct,
})
: t('layers.traffic.liveCoverage', {
percent: coveragePct,
})
: t('layers.traffic.simulatedNoMatches'),
? t(
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
{
percent: coveragePct,
},
)
: t(
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
{
percent: coveragePct,
},
)
: t('SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)'),
};
}
// Keyless simulation — one terse line that names the mode and the remedy
@@ -312,8 +320,8 @@ export function createModel({ state: layerState, services, parts, source }) {
mode,
error: null,
loadingLabel: statusUnavailable
? t('layers.traffic.simUnavailable')
: t('layers.traffic.simKeyless'),
? t('SIMULATED — traffic service unreachable')
: t('SIMULATED — add TomTom key for live'),
};
}
+11 -11
View File
@@ -21,13 +21,13 @@ export function createCards({
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }),
t('layers.vessel.hudTypeLine', {
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24),
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
}
@@ -35,7 +35,7 @@ export function createCards({
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('layers.vessel.hudIdle');
el.textContent = t('AIS: --');
}
function trimHudValue(value, maxLength) {
@@ -90,7 +90,7 @@ export function createCards({
const direction = record.heading ?? record.course;
const details = [
[
vesselTypeShort(record) || t('layers.vessel.fallback'),
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '),
@@ -99,7 +99,7 @@ export function createCards({
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0;
details.push(
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`,
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
);
return {
id: vesselOverlayEntryId(record),
@@ -156,7 +156,7 @@ export function createCards({
function displayVesselName(record) {
const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
return record.mmsi ? `MMSI ${record.mmsi}` : t('layers.vessel.fallback');
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
}
function formatSpeed(speed) {
@@ -168,10 +168,10 @@ export function createCards({
}
function formatPositionTime(record) {
if (!record.lastPositionUtc) return t('layers.vessel.posLive');
if (!record.lastPositionUtc) return t('POS: LIVE');
const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive');
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) });
if (Number.isNaN(date.getTime())) return t('POS: LIVE');
return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
}
return {
updateSelectedVesselHud,
+3 -3
View File
@@ -38,7 +38,7 @@ export function createLifecycle({
state.feed.firstConnectStartedAt = startedAt;
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.feed.error = null;
state.feed.loadingLabel = t('layers.vessel.awaitingFirstPosition');
state.feed.loadingLabel = t('awaiting first AIS position…');
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
@@ -60,8 +60,8 @@ export function createLifecycle({
state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
state.feed.stale = state.feed.count > 0;
}, delayMs);
}
+8 -10
View File
@@ -1,14 +1,14 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { t, msg } from '../../i18n/index.js';
// The vessels ingestion owner is a portable module graph (it also runs in the
// server-side source builds), so it writes stable English feed labels into
// state. Localize those known labels here, at the presentation read; anything
// else (a bespoke manager label) passes through verbatim.
const FEED_STATE_LABEL_KEYS = Object.freeze({
'refreshing...': 'layers.meta.refreshing',
'loading...': 'layers.meta.loading',
'awaiting first AIS position…': 'layers.vessel.awaitingFirstPosition',
'refreshing...': msg('refreshing...'),
'loading...': msg('loading...'),
'awaiting first AIS position…': msg('awaiting first AIS position…'),
});
function localizeFeedLabel(raw) {
@@ -98,8 +98,8 @@ export function createQueries({
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
if (AIS_HEALTHY_STATUSES.has(status)) {
return payload?.lastMessageAt
? t('layers.vessel.awaitingPositions')
: t('layers.vessel.awaitingFirstMessage');
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
}
if (!status) return null;
const detail =
@@ -158,9 +158,7 @@ export function createQueries({
error: emptyCoverage
? null
: deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0
? t('layers.vessel.awaitingPositions')
: null),
(acceptedRowCount === 0 ? t('awaiting usable AIS positions…') : null),
};
}
@@ -532,7 +530,7 @@ export function createQueries({
lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? t('layers.vessel.awaitingFirstPosition')
? t('awaiting first AIS position…')
: localizeFeedLabel(state.feed.loadingLabel),
error: state.feed.error,
stale: state.feed.stale,
+3 -3
View File
@@ -5,7 +5,7 @@ import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosu
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { t } from './i18n/index.js';
import { msg, t } from './i18n/index.js';
// Exercise the installed event routes and central close method, without WebGL.
const source = readShellSource();
@@ -108,9 +108,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now };
document.defaultView = window;
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't',
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame', 't', 'msg',
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t,
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {}, t, msg,
);
const saves = [];
const claims = [];
+4 -4
View File
@@ -41,11 +41,11 @@ export function mapStackChipModel(stack, activeId) {
// Stack labels are provider/dataset names (keep-English); the availability
// sentence around them is copy and localizes here at the model boundary.
const fallbackReason = requiresIon
? t('setup.keySetup.requirement', {
? t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'),
})
: t('setup.mapStack.unavailableReason', {
label: label || t('setup.mapStack.fallbackName'),
: t('{label} is unavailable', {
label: label || t('This map stack'),
});
const unavailableHint = available
? ''
@@ -126,7 +126,7 @@ export function renderMapStackChips(
if (!model.available) {
chip.setAttribute(
'aria-label',
t('setup.mapStack.unavailableAriaLabel', {
t('{label} unavailable: {hint}', {
label: model.label,
hint: model.unavailableHint,
}),
+1 -1
View File
@@ -16,5 +16,5 @@ export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
const envVars = keySetupRequirementEnvVars('google-maps');
return `${t('setup.keySetup.requirement', { envVars })} — or a Cesium ion token for the ion-hosted route`;
return `${t('Needs {envVars} — add it in Provider Settings', { envVars })} — or a Cesium ion token for the ion-hosted route`;
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function createDefaultMapSources({
descriptor,
available: !descriptor.requiresIon || hasIon,
unavailableReason: descriptor.requiresIon
? t('setup.keySetup.requirement', {
? t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'),
})
: null,
+20 -18
View File
@@ -26,7 +26,7 @@ import {
getSceneAppendRecipeById,
} from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js';
import { createSceneSharing } from './sharing.js';
@@ -67,11 +67,11 @@ import {
// boundary + persisted data); only the presentation of an UNRENAMED built-in
// localizes, so an operator's renamed scene is never masked by the catalog.
const RECIPE_TITLE_KEYS = Object.freeze({
'flights-radar': 'setup.scenes.recipe.flightsRadar',
'orbital-watch': 'setup.scenes.recipe.orbitalWatch',
'thermal-threats': 'setup.scenes.recipe.thermalThreats',
'city-overload': 'setup.scenes.recipe.cityOverload',
'omniscience-pullback': 'setup.scenes.recipe.omnisciencePullback',
'flights-radar': msg('Global Flights Radar'),
'orbital-watch': msg('Orbital Watch'),
'thermal-threats': msg('Thermal Threat Board'),
'city-overload': msg('City Overload'),
'omniscience-pullback': msg('Omniscience Pullback'),
});
/**
@@ -198,7 +198,7 @@ export class SceneDirector {
this._presentation = {
status: this._storageReadError
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
: t('setup.scenes.statusReady'),
: t('Ready'),
progress: 0,
runtime: '',
playbackActive: false,
@@ -396,7 +396,7 @@ export class SceneDirector {
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded')
this._presentation.status = t('setup.scenes.status.loaded', {
this._presentation.status = t('Loaded: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
});
@@ -954,14 +954,14 @@ export class SceneDirector {
const camera = this.styleManager.getCameraState();
if (!camera) {
this._updateStatus(t('setup.scenes.status.captureCameraNotReady'));
this._updateStatus(t('Cannot capture shot: camera not ready'));
return;
}
const shot = normalizeShot(
{
id: uid('shot'),
title: t('setup.scenes.status.shotTitleDefault', {
title: t('Shot {n}', {
n: scene.shots.length + 1,
}),
durationSec: DEFAULT_SHOT_DURATION_SEC,
@@ -978,7 +978,7 @@ export class SceneDirector {
this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('setup.scenes.status.captured', {
t('Captured: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
}),
@@ -995,7 +995,7 @@ export class SceneDirector {
const shot = scene.shots.find((item) => item.id === this._selectedShotId);
if (!shot) {
this._updateStatus(t('setup.scenes.status.selectShotFirst'));
this._updateStatus(t('Select a shot first'));
return;
}
@@ -1011,7 +1011,7 @@ export class SceneDirector {
this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('setup.scenes.status.updated', {
t('Updated: {scene} / {shot}', {
scene: scene.title,
shot: shot.title,
}),
@@ -1610,7 +1610,7 @@ export class SceneDirector {
this._claimingCamera = false;
}
if (claimed === false) {
this._updateStatus(t('setup.scenes.status.cameraUnavailable'));
this._updateStatus(t('Camera unavailable — exit cockpit first'));
return false;
}
return true;
@@ -1745,7 +1745,7 @@ export class SceneDirector {
if (!queue.length) return { started: false, reason: 'scene-complete' };
}
if (!queue.length) {
this._updateStatus(t('setup.scenes.status.noShotsToRun'));
this._updateStatus(t('No shots to run'));
return { started: false, reason: 'no-shots' };
}
@@ -1829,7 +1829,7 @@ export class SceneDirector {
});
} catch (error) {
this._updateStatus(
t('setup.scenes.status.runError', {
t('Error: {message}', {
message: error.message || 'run failed',
}),
);
@@ -2006,7 +2006,7 @@ export class SceneDirector {
link.click();
link.remove();
URL.revokeObjectURL(url);
this._presentation.status = t('setup.scenes.status.projectExported');
this._presentation.status = t('Project exported');
this._publish({ type: 'project-exported', project: this._project });
}
@@ -2268,7 +2268,9 @@ export class SceneDirector {
`[Scenes] Could not exit ${mode}:`,
result.error || 'unknown reason',
);
this._updateStatus(t('setup.scenes.status.contextExitFailed', { mode }));
this._updateStatus(
t('Could not exit {mode} — scene layers may be refused', { mode }),
);
this._logEvent('context_mode_exit_failed', {
mode,
error: result.error || null,
+2 -2
View File
@@ -16,7 +16,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
director._renderSceneSelect();
director._renderShotList();
director._updateStatus(
t('setup.scenes.status.runningShot', {
t('Running {index}/{total}: {scene} / {shot}', {
index: index + 1,
total,
scene: scene.title,
@@ -78,7 +78,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
},
complete() {
director._setProgress(1);
director._updateStatus(t('setup.scenes.status.runComplete'));
director._updateStatus(t('Scene run complete'));
director._logEvent('scene_run_complete', {});
},
};
-5
View File
@@ -11,8 +11,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
plugins: [plugin],
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
defaultLocale: 'fr',
secondaryLocale: 'en',
});
// The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check');
@@ -40,9 +38,6 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
assert.deepEqual(config.define, {
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
// Locale pair (i18n): threaded like the other explicit browser inputs.
'import.meta.env.GEV_DEFAULT_LOCALE': '"fr"',
'import.meta.env.GEV_SECONDARY_LOCALE': '"en"',
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
});
assert.equal(
+22 -30
View File
@@ -38,7 +38,7 @@ import { ShellFeedback } from './shellFeedback.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.js';
import { availableLocales } from '../i18n/locale.js';
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -706,15 +706,9 @@ export class StyleManager extends ShellFacade {
}
/**
* Renders the command-dock locale switch (runtime-built since the
* configurable-pair stage): one button per locale availableLocales()
* yields — the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair,
* already deduped against the always-shipped English fallback. Labels are
* uppercase locale codes (EN/ES/FR); aria-labels come from
* shell.locale.<code>.ariaLabel in the active catalog. Pressed state is
* synced from the resolved locale at boot; a click persists the choice and
* reloads the page, so no live re-apply of document translations is needed
* here (persistLocaleAndReload strips ?lang and keeps the hash).
* Renders the command-dock locale switch: one button per shipped locale,
* labelled with its code and named in its own language. A click stores the
* choice and reloads, so nothing re-renders live.
* @returns {void}
*/
_initLocaleSelector() {
@@ -723,13 +717,14 @@ export class StyleManager extends ShellFacade {
);
if (!container) return;
const active = getLocale();
for (const locale of availableLocales()) {
for (const locale of LOCALES) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'dock-locale-btn';
button.dataset.locale = locale;
button.textContent = locale.toUpperCase();
button.setAttribute('aria-label', t(`shell.locale.${locale}.ariaLabel`));
button.setAttribute('aria-label', LOCALE_NAMES[locale]);
button.lang = locale;
const isActive = locale === active;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', String(isActive));
@@ -770,15 +765,15 @@ export class StyleManager extends ShellFacade {
_applyRuntimeStaticHeaderText() {
this._setHeaderText(
document.querySelector('#control-panel .panel-title'),
t('cockpit.presets.title'),
t('VISUAL PRESETS'),
);
this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'),
t('cockpit.location.toolbarLabel'),
t('LOCATION'),
);
this._setHeaderText(
document.querySelector('#pp-toggles .pp-header-label'),
t('cockpit.display.title'),
t('DISPLAY'),
);
// The cockpit context kicker ('CONTACT') is visible on the map itself, and
// the briefing kicker shares its element with the live-dot <i>; both are
@@ -786,7 +781,7 @@ export class StyleManager extends ShellFacade {
// text node keeps renderBriefPage's leading-space convention.
this._setHeaderText(
document.querySelector('.cockpit-context-kicker'),
t('cockpit.context.kicker'),
t('CONTACT'),
);
// The phase-2 key-only standby description: one span holds BOTH mode
// descriptions split by a literal <br>, so no attribute write was possible
@@ -797,14 +792,18 @@ export class StyleManager extends ShellFacade {
);
if (standbyDescription) {
standbyDescription.replaceChildren(
document.createTextNode(t('cockpit.context.standbyContactsDesc')),
document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'),
document.createTextNode(t('cockpit.context.standbyMissionsDesc')),
document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
);
}
const briefKicker = document.querySelector('#cockpit-brief-kicker');
if (briefKicker) {
this._setHeaderText(briefKicker, ` ${t('cockpit.brief.kicker')}`);
this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`);
}
}
@@ -822,11 +821,8 @@ export class StyleManager extends ShellFacade {
// runtime write instead. The locale resolves before UI init and never
// changes without a reload, so an init-time write is sufficient.
if (this._mapSourceLabel)
this._mapSourceLabel.textContent = t('cockpit.presets.mapSourceLabel');
this._mapStackChips.setAttribute(
'aria-label',
t('cockpit.presets.mapSourceChipsAriaLabel'),
);
this._mapSourceLabel.textContent = t('MAP SOURCE');
this._mapStackChips.setAttribute('aria-label', t('Map source'));
this._mapSourceControls?.destroy();
this._mapSourceControls = createMapSourceControls({
@@ -1149,7 +1145,7 @@ export class StyleManager extends ShellFacade {
const { cctvLayer } = this.services;
if (this._disposed) return false;
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
this._showToast(t('cockpit.cctv.toastLayerUnavailable'));
this._showToast(t('CCTV layer unavailable'));
return false;
}
const enabled = this._dataManager.isEnabled('cctv');
@@ -1598,11 +1594,7 @@ export class StyleManager extends ShellFacade {
this._lifetime.listen(this._shareBtn, 'click', async () => {
const success = await this.shareLinkManager.copyLink();
if (!this._disposed)
this._showToast(
success
? t('cockpit.share.toastCopied')
: t('cockpit.share.toastCopyFailed'),
);
this._showToast(success ? t('Link copied!') : t('Copy failed'));
});
}
+1 -1
View File
@@ -145,7 +145,7 @@ export function _initCctvPanel() {
},
{ origin: 'user' },
);
this.actions.showToast(t('cockpit.cctv.toastCalibrationSaved'));
this.actions.showToast(t('CCTV calibration saved'));
});
this.listen(this._cctvCalibResetBtn, 'click', () => {
+2 -4
View File
@@ -83,7 +83,7 @@ export function _resetCctvCalibration() {
},
{ origin: 'user' },
);
this.actions.showToast(t('cockpit.cctv.toastCalibrationReset'));
this.actions.showToast(t('CCTV calibration reset'));
}
export function _beginCctvCalValueEdit(chip) {
@@ -164,9 +164,7 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
if (this._cctvAdjustBtn) {
const adjustOn = !!this._cctvState?.calibrationMode;
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
this._cctvAdjustBtn.textContent = adjustOn
? t('layers.cctv.adjustOn')
: t('layers.cctv.adjustLabel');
this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST');
this._cctvAdjustBtn.disabled = !canCalibrate;
}
if (this._cctvCalReadout) {
+3 -3
View File
@@ -88,19 +88,19 @@ export function _settleCctvFrame(token, src, ok) {
export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) {
this._cctvSourceBadge.textContent = t('layers.cctv.sourceUnknown');
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
this._cctvSourceBadge.dataset.frameState = 'idle';
return;
}
const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameLoading');
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
this._cctvSourceBadge.dataset.frameState = 'loading';
return;
}
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
this._cctvSourceBadge.textContent = t('cockpit.cctv.frameUnavailable');
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
this._cctvSourceBadge.dataset.frameState = 'error';
return;
}
+48 -43
View File
@@ -1,14 +1,14 @@
import { t } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
return t('cockpit.cctv.calBadgeCalibrated');
return t('CALIBRATED');
case 'curated':
return t('cockpit.cctv.calBadgeCurated');
return t('CURATED');
case 'raw-prior':
return t('cockpit.cctv.calBadgeRawPrior');
return t('RAW PRIOR');
default:
return '--';
}
@@ -53,9 +53,7 @@ export function _renderCctvState(state) {
if (this._cctvEnableBtn) {
this._cctvEnableBtn.classList.toggle('active', enabled);
this._cctvEnableBtn.textContent = enabled
? t('layers.cctv.toggleOn')
: t('layers.cctv.toggleOff');
this._cctvEnableBtn.textContent = enabled ? t('CCTV ON') : t('CCTV OFF');
}
if (this._cctvSelect) {
@@ -103,10 +101,10 @@ export function _renderCctvState(state) {
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent =
mode === 'viewshed'
? t('cockpit.cctv.coverageViewshedOn')
? t('VIEWSHED ON')
: mode === 'on'
? t('layers.cctv.coverageOn')
: t('layers.cctv.coverageOff');
? t('COVERAGE ON')
: t('COVERAGE OFF');
this._cctvCoverageBtn.disabled = !enabled;
}
@@ -114,8 +112,8 @@ export function _renderCctvState(state) {
const autoHop = !!state?.autoHop;
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
this._cctvAutoHopBtn.textContent = autoHop
? t('layers.cctv.autoHopOn')
: t('layers.cctv.autoHopOff');
? t('AUTO HOP ON')
: t('AUTO HOP OFF');
this._cctvAutoHopBtn.disabled = !enabled;
}
@@ -123,8 +121,8 @@ export function _renderCctvState(state) {
const showProjection = state?.showProjection !== false;
this._cctvProjectionBtn.classList.toggle('active', showProjection);
this._cctvProjectionBtn.textContent = showProjection
? t('layers.cctv.projectionOn')
: t('layers.cctv.projectionOff');
? t('PROJECTION ON')
: t('PROJECTION OFF');
this._cctvProjectionBtn.disabled = !enabled;
}
@@ -139,8 +137,8 @@ export function _renderCctvState(state) {
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
this._cctvQualityChip.textContent = dirty
? t('cockpit.cctv.calChipEdited')
: t('cockpit.cctv.calChipTemplate', {
? t('CAL · EDITED (UNSAVED)')
: t('CAL · {badge}', {
badge: this._calBadgeLabel(badge),
});
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
@@ -163,25 +161,36 @@ export function _renderCctvState(state) {
? this._calBadgeLabel(activeCamera.calBadge)
: '';
const projLabel =
state?.showProjection !== false
? t('cockpit.cctv.metaProjectionMonitor')
: t('cockpit.cctv.metaProjectionOff');
this._cctvMeta.textContent = t('cockpit.cctv.metaTemplate', {
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
});
state?.showProjection !== false ? t('MONITOR') : t('OFF');
this._cctvMeta.textContent = t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
);
} else if (cameras.length > 0) {
this._cctvMeta.textContent = enabled
? t('cockpit.cctv.metaCamerasClick', { count: cameras.length })
: t('cockpit.cctv.metaCamerasEnable', { count: cameras.length });
? tn(
cameras.length,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
cameras.length,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);
} else {
this._cctvMeta.textContent = t('layers.cctv.metaIdle');
this._cctvMeta.textContent = t(
'Enable CCTV to load camera intersections',
);
}
}
@@ -229,13 +238,15 @@ export function _renderCctvState(state) {
}
this._syncCctvSourceBadge(activeCamera, enabled);
this._typeCctvSummary(state?.summary || t('layers.cctv.summaryIdle'));
this._typeCctvSummary(
state?.summary ||
t('Enable CCTV to start camera-linked intelligence summaries.'),
);
}
export function _typeCctvSummary(text) {
if (this.destroyed || !this._cctvSummary) return;
const nextText =
String(text || '').trim() || t('cockpit.cctv.summaryNoneAvailable');
const nextText = String(text || '').trim() || t('No summary available.');
if (nextText === this._lastCctvSummaryText) return;
this._lastCctvSummaryText = nextText;
@@ -267,10 +278,7 @@ export function _updateCctvSyncChip(loading, enabled) {
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = true;
this.actions.setSplitFlapText(
this._cctvSyncLabel,
t('cockpit.cctv.syncLoadingFrames'),
);
this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
// The counter is left plain on purpose: it ticks every few frames
// during a grid load, and flapping it would read as a slot machine.
this._cctvSyncProgress.textContent = `${loaded}/${total}`;
@@ -281,10 +289,7 @@ export function _updateCctvSyncChip(loading, enabled) {
if (this._cctvChipWasBusy && enabled && total > 0) {
// Load just completed — flash the final count, then auto-hide.
this._cctvChipWasBusy = false;
this.actions.setSplitFlapText(
this._cctvSyncLabel,
t('cockpit.cctv.syncGridReady'),
);
this.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
this._cctvSyncProgress.textContent = `${total}/${total}`;
this._cctvSyncChip.classList.add('visible');
clearTimeout(this._cctvChipHideTimer);
+2 -2
View File
@@ -15,8 +15,8 @@ export function bindClearLayersControl(button, clear) {
button.setAttribute(
'aria-label',
busy
? t('cockpit.actions.clearLayersBusyAria')
: t('shell.actions.clearLayers.ariaLabel'),
? t('Clearing selected data layers')
: t('Clear selected data layers'),
);
},
destroy() {
+35 -35
View File
@@ -1,5 +1,5 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
@@ -15,18 +15,18 @@ import {
// so it returns stable English labels; localize them here, at the cockpit
// presentation boundary (docs/TRANSLATORS.md).
const WEATHER_CONDITION_KEYS = Object.freeze({
'CONDITIONS UNKNOWN': 'layers.weather.conditionsUnknown',
CLEAR: 'layers.weather.clear',
'PARTLY CLOUDY': 'layers.weather.partlyCloudy',
OVERCAST: 'layers.weather.overcast',
FOG: 'layers.weather.fog',
DRIZZLE: 'layers.weather.drizzle',
RAIN: 'layers.weather.rain',
SNOW: 'layers.weather.snow',
'RAIN SHOWERS': 'layers.weather.rainShowers',
'SNOW SHOWERS': 'layers.weather.snowShowers',
THUNDERSTORM: 'layers.weather.thunderstorm',
'MIXED CONDITIONS': 'layers.weather.mixed',
'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
CLEAR: msg('CLEAR'),
'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
OVERCAST: msg('OVERCAST'),
FOG: msg('FOG'),
DRIZZLE: msg('DRIZZLE'),
RAIN: msg('RAIN'),
SNOW: msg('SNOW'),
'RAIN SHOWERS': msg('RAIN SHOWERS'),
'SNOW SHOWERS': msg('SNOW SHOWERS'),
THUNDERSTORM: msg('THUNDERSTORM'),
'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
});
export function showBriefPage(index, { manual = false } = {}) {
@@ -80,14 +80,18 @@ export function setBriefAutoRotate(enabled) {
// these t() re-writes are same-value overrides that localize the toggle
// under other locales (byte-identical under 'en').
const labelKey = this.briefAutoRotateEnabled
? 'cockpit.brief.autoOn'
: 'cockpit.brief.autoOff';
? msg('CYCLE ON')
: msg('CYCLE OFF');
this.briefAutoToggle.textContent = t(labelKey);
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
this.briefAutoToggle.title = t(
this.briefAutoRotateEnabled
? 'cockpit.brief.autoTitleOn'
: 'cockpit.brief.autoTitle',
? msg(
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
)
: msg(
'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.',
),
);
}
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
@@ -127,7 +131,7 @@ export function stopBriefRotation() {
export function updateLocalPosition(info) {
if (!this.localCoordinates) return;
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
this.localCoordinates.textContent = t('cockpit.brief.positionUnavailable');
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
return;
}
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`;
@@ -208,14 +212,14 @@ export function renderRegionalBriefStatus(status, info) {
this.newsStatus.dataset.state = status;
this.newsStatus.textContent =
status === 'loading'
? t('cockpit.brief.newsAcquiring')
: t('cockpit.brief.newsUnavailable');
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading')
this.localPlace.textContent = t('cockpit.brief.localResolving');
this.localPlace.textContent = t('RESOLVING REGION');
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = t('cockpit.brief.regionUnavailable');
this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(info);
}
@@ -226,8 +230,8 @@ export function renderRegionalBrief(payload, info) {
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent =
payload?.newsStatus === 'empty'
? t('cockpit.brief.newsEmpty')
: t('cockpit.brief.newsUnavailable');
? t('NO RECENT LOCATION MATCHES')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (this.newsList) {
this.newsList.replaceChildren(
@@ -240,8 +244,8 @@ export function renderRegionalBrief(payload, info) {
const title = document.createElement('strong');
title.textContent = article.title;
const metadata = document.createElement('span');
metadata.textContent = t('cockpit.brief.articleMetaTemplate', {
domain: article.domain || t('cockpit.brief.metadataSourceFallback'),
metadata.textContent = t('{domain} · {age}', {
domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt),
});
link.append(title, metadata);
@@ -252,9 +256,7 @@ export function renderRegionalBrief(payload, info) {
}
const placeLabel =
payload?.place?.label ||
payload?.place?.country ||
t('cockpit.brief.regionUnavailable');
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info);
const weather = payload?.weather;
@@ -282,10 +284,10 @@ export function renderRegionalBrief(payload, info) {
}
if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? t('cockpit.brief.cloudTemplate', {
? t('CLOUD {pct}%', {
pct: Math.round(weather.cloudCoverPct),
})
: t('cockpit.brief.cloudUnknown');
: t('CLOUD UNKNOWN');
}
if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(
@@ -297,10 +299,8 @@ export function renderRegionalBrief(payload, info) {
if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = t('cockpit.brief.newsSourceLine', {
source: String(
payload?.newsSource || t('cockpit.brief.kickerNews'),
).toUpperCase(),
this.briefSource.textContent = t('{source} · LOCATION QUERY', {
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
});
}
this.scheduleContextLayout();
+19 -19
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { t } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
import {
bearingBetweenCoordinates,
formatCockpitContextScope,
@@ -21,8 +21,8 @@ export function updateContext(info, heading) {
this.pushCockpitSignal(
'context-status',
'info',
t('cockpit.signal.contextStandby'),
t('cockpit.signal.contextStandbyHint'),
t('CONTEXT STANDBY'),
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
);
return;
}
@@ -59,7 +59,7 @@ export function updateContext(info, heading) {
this.contextUncertainty.textContent =
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
this.contextUncertainty.textContent = t(
'cockpit.context.uncertaintyContactLost',
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
}
// The cue changes the footer's height; re-run layout once on the way in
@@ -68,10 +68,10 @@ export function updateContext(info, heading) {
this.pushCockpitSignal(
'context-status',
'warning',
t('cockpit.signal.contactLostTitle', {
t('CONTACT LOST · {subject}', {
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
}),
t('cockpit.signal.contactLostDetail'),
t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
);
return;
}
@@ -92,15 +92,15 @@ export function updateContext(info, heading) {
const closestLabel = this.services.formatAwarenessLabel(closest);
if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest
? t('cockpit.context.nearestTemplate', {
? t('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel,
})
: t('cockpit.context.nearestEmpty');
: t('NO AVAILABLE EXAMPLE');
this.contextNearestLabel.setAttribute(
'aria-label',
closest && closestLabel === '—'
? t('cockpit.context.nearestUnavailableAria', {
? t('{cohort}, Unavailable', {
cohort: closest.cohort.label,
})
: this.contextNearestLabel.textContent,
@@ -153,24 +153,24 @@ export function updateContext(info, heading) {
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
if (relative === null)
this.contextBearing.textContent = t('cockpit.context.bearingNone');
if (relative === null) this.contextBearing.textContent = t('BRG —');
else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('cockpit.context.bearingAhead');
this.contextBearing.textContent = t('AHEAD');
else {
this.contextBearing.textContent = t('cockpit.context.bearingSide', {
side:
relative < 0
? t('cockpit.context.sideLeft')
: t('cockpit.context.sideRight'),
this.contextBearing.textContent = t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
}
if (this.contextUncertainty) {
this.contextUncertainty.textContent = unknownCount
? t('cockpit.context.uncertaintyInputsUnknown', { count: unknownCount })
: t('cockpit.context.uncertaintyInputsCurrent');
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
}
if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
+29 -32
View File
@@ -32,10 +32,7 @@ export function updateHud(
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
if (this.callsign) {
this.callsign.textContent =
info.callsign ||
info.registration ||
info.icao24 ||
t('cockpit.hud.fallbackCallsign');
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
}
const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384
@@ -133,19 +130,20 @@ export function updateHud(
// State-sibling keys at the writer: each feed state translates
// independently so no state can leak English under other locales.
const feedState = this.surfaceAcquiring
? t('cockpit.hud.metaFeedAcquiringSurface')
? t('ACQUIRING SURFACE')
: this.surfaceFallback
? t('cockpit.hud.metaFeedSurfaceFallback')
? t('SURFACE FALLBACK')
: info.stale
? t('cockpit.hud.metaFeedStale')
: t('cockpit.hud.metaFeedLive');
this.aircraftMeta.textContent = t('cockpit.hud.aircraftMetaTemplate', {
aircraftClass:
info.layerId === 'military'
? t('cockpit.hud.metaClassMilitary')
: t('cockpit.hud.metaClassCommercial'),
feedState,
});
? t('STALE FEED')
: t('LIVE TRACK');
this.aircraftMeta.textContent = t(
'{aircraftClass} · {feedState} · COURSE ALIGNED',
{
aircraftClass:
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'),
feedState,
},
);
}
this.updateRoute(info);
if (
@@ -170,14 +168,13 @@ export function updateRoute(info) {
const validDestination =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const routeLabel = (airport) =>
[airport?.code, airport?.name].filter(Boolean).join(' · ') ||
t('cockpit.route.unknownEndpoint');
[airport?.code, airport?.name].filter(Boolean).join(' · ') || t('UNKNOWN');
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) {
this.routeStatus.textContent = validDestination
? t('cockpit.route.statusArrowEstimated')
: t('cockpit.route.statusUnavailable');
? t('ARROW · ESTIMATED DIRECTION')
: t('ROUTE DATA UNAVAILABLE');
}
if (this.route) this.route.hidden = !origin && !destination;
if (
@@ -211,7 +208,7 @@ export function updateRoute(info) {
);
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = t('cockpit.route.directionLabel', {
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
});
}
@@ -222,14 +219,12 @@ export function syncWeatherToggle(enabled) {
const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active));
const weatherLabel = active
? t('cockpit.context.weatherDisableAriaLabel')
: t('cockpit.context.weatherEnableAriaLabel');
? t('Disable cockpit weather effects')
: t('Enable cockpit weather effects');
this.weatherToggle.setAttribute('aria-label', weatherLabel);
this.weatherToggle.title = weatherLabel;
if (this.weatherState) {
this.weatherState.textContent = active
? t('cockpit.context.weatherStateOn')
: t('cockpit.context.weatherStateOff');
this.weatherState.textContent = active ? t('ON') : t('OFF');
}
}
@@ -248,19 +243,21 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
const names = {
optical: 'Normal',
crt: 'CRT',
nvg: t('cockpit.vision.styleNameNightVision'),
thermal: t('cockpit.vision.styleNameThermal'),
anime: t('cockpit.vision.styleNameAnime'),
noir: t('cockpit.vision.styleNameNoir'),
snow: t('cockpit.vision.styleNameSnow'),
nvg: t('Night vision'),
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
snow: t('Snow'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute(
'aria-label',
t('cockpit.vision.currentAriaTemplate', { style: names[next] }),
t('Current cockpit vision style: {style}. Activate for next style.', {
style: names[next],
}),
);
this.visionCurrent.title = t('cockpit.vision.currentTitleTemplate', {
this.visionCurrent.title = t('Current style: {style} — click for next', {
style: names[next],
});
}
+6 -12
View File
@@ -1,5 +1,5 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
@@ -45,16 +45,10 @@ export function setContextCollapsed(collapsed) {
);
this.contextToggle.setAttribute(
'aria-label',
t(
expanded
? 'cockpit.context.toggleExpandAriaLabel'
: 'cockpit.context.toggleAriaLabel',
),
t(expanded ? msg('Expand Contact panel') : msg('Collapse Contact panel')),
);
this.contextToggle.title = t(
expanded
? 'cockpit.context.toggleExpandTitle'
: 'cockpit.context.toggleTitle',
expanded ? msg('Expand contact panel') : msg('Collapse contact panel'),
);
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
@@ -78,12 +72,12 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
'aria-label',
t(
expanded
? 'cockpit.brief.collapseAriaLabel'
: 'cockpit.brief.expandAriaLabel',
? msg('Collapse cockpit briefing panel')
: msg('Expand cockpit briefing panel'),
),
);
this.signalToggle.title = t(
expanded ? 'cockpit.brief.collapseTitle' : 'cockpit.brief.expandTitle',
expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'),
);
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
if (icon)
+13 -13
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -46,21 +46,21 @@ export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
export const COCKPIT_BRIEF_PAGES = [
{
id: 'signals',
kickerKey: 'cockpit.brief.kicker',
subtitleKey: 'cockpit.brief.subtitle',
sourceKey: 'cockpit.brief.sourceNote',
kickerKey: msg('LIVE SIGNALS'),
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
},
{
id: 'news',
kickerKey: 'cockpit.brief.kickerNews',
subtitleKey: 'cockpit.brief.subtitleNews',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
// RSS provider attribution is a keep-English boundary string.
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
},
{
id: 'local',
kickerKey: 'cockpit.brief.kickerLocal',
subtitleKey: 'cockpit.brief.subtitleLocal',
kickerKey: msg('LOCAL INFO'),
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
// Data-source attribution is a keep-English boundary string.
source: 'NATURAL EARTH · OPEN-METEO · UTC',
},
@@ -84,17 +84,17 @@ export function isRenderedOnScreen(element) {
export function formatCockpitBriefAge(value) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return t('cockpit.brief.age.timeUnknown');
if (!Number.isFinite(timestamp)) return t('TIME UNKNOWN');
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 60) return t('cockpit.brief.age.minutes', { count: minutes });
if (minutes < 60) return t('{count}M AGO', { count: minutes });
const hours = Math.round(minutes / 60);
return hours < 48
? t('cockpit.brief.age.hours', { count: hours })
: t('cockpit.brief.age.days', { count: Math.round(hours / 24) });
? t('{count}H AGO', { count: hours })
: t('{count}D AGO', { count: Math.round(hours / 24) });
}
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('cockpit.brief.wind.dirUnknown');
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const normalized = ((value % 360) + 360) % 360;
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`;
+15 -11
View File
@@ -1,5 +1,5 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
import { t } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
export function renderCockpitSignals() {
if (!this.signalList) return;
@@ -58,7 +58,7 @@ export function renderCockpitSignals() {
if (item.target) {
heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id;
const label = t('cockpit.signal.selectFlightAria', { title: item.title });
const label = t('Select flight {title}', { title: item.title });
if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title);
@@ -127,11 +127,11 @@ export function updateCockpitSignals(snapshot, unknownCount) {
key: `flight:${subject.layerId}:${subject.id}`,
tone: 'track',
title: subject.label || subject.id,
detail: t('cockpit.signal.contactCurrent', {
detail: t('{aircraftClass} · CURRENT', {
aircraftClass:
subject.layerId === 'military'
? t('cockpit.signal.classMilitary')
: t('cockpit.signal.classCommercial'),
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
}),
target: { layerId: subject.layerId, id: String(subject.id) },
distanceM: -1,
@@ -151,14 +151,14 @@ export function updateCockpitSignals(snapshot, unknownCount) {
// contact reads as its registration here too. Same helper the
// Context panel's nearest list uses.
title: this.services.formatAwarenessLabel(item),
detail: t('cockpit.signal.contactRange', {
detail: t('{aircraftClass} · {distance}', {
aircraftClass:
cohort.id === 'military'
? t('cockpit.signal.classMilitary')
: t('cockpit.signal.classCommercial'),
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
distance: Number.isFinite(item.distanceM)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
: t('cockpit.signal.distanceUnknown'),
: t('DISTANCE UNKNOWN'),
}),
target: { layerId: cohort.id, id: String(id) },
distanceM: item.distanceM ?? Infinity,
@@ -179,8 +179,12 @@ export function updateCockpitSignals(snapshot, unknownCount) {
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status',
tone: 'warning',
title: t('cockpit.signal.inputsUnknown', { count: unknownCount }),
detail: sources || t('cockpit.signal.sourceStatusUnavailable'),
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
+6 -9
View File
@@ -59,8 +59,8 @@ export function syncTr3bToggle(info) {
this.tr3bToggle.hidden = !icao24;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
this.tr3bToggle.title = converted
? t('cockpit.context.tr3bRestoreTitle')
: t('cockpit.context.tr3bTitle');
? t('Restore real aircraft')
: t('Reclassify as TR-3B');
}
export function syncEntry() {
@@ -184,20 +184,17 @@ export function enter() {
this.showBriefPage(0);
this.startBriefRotation();
const trackLabel =
info.callsign ||
info.registration ||
info.icao24 ||
t('cockpit.hud.fallbackCallsign');
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
this.pushCockpitSignal(
'track',
'track',
t('cockpit.signal.trackAcquired'),
t('cockpit.signal.trackDetail', {
t('TRACK ACQUIRED'),
t('{label} · COURSE {heading}°', {
label: trackLabel,
heading: `${trackHeading}°`,
heading: trackHeading,
}),
);
this.updateHud(info, performance.now(), true);
+14 -10
View File
@@ -1,4 +1,4 @@
import { t } from '../i18n/index.js';
import { t, tn } from '../i18n/index.js';
import {
settleUserFacingContextAction,
contextModeWord,
@@ -6,7 +6,7 @@ import {
export async function _runUserFacingContextAction(
operation,
message = t('cockpit.context.toastRestoreFailed'),
message = t('Context could not restore every layer; try again'),
{ falseIsFailure = true, localizedMessage = null } = {},
) {
if (this.destroyed) return false;
@@ -223,25 +223,29 @@ export function clearSelectedLayers() {
const operation = managerOperation
.then((result) => {
if (result.targetIds.length === 0) {
this.showToast(t('layers.clear.toast.noneSelected'));
this.showToast(t('No selected data layers'));
} else if (result.notClearedIds.length > 0) {
this.showToast(
t('layers.clear.toast.notCleared', {
count: result.notClearedIds.length,
}),
tn(
result.notClearedIds.length,
'{count} data layer could not be cleared',
'{count} data layers could not be cleared',
),
);
} else {
this.showToast(
t('layers.clear.toast.cleared', {
count: result.clearedIds.length,
}),
tn(
result.clearedIds.length,
'Cleared {count} data layer',
'Cleared {count} data layers',
),
);
}
return result;
})
.catch((error) => {
console.warn('[Data] clear selected layers failed', error);
this.showToast(t('cockpit.actions.clearLayersFailedToast'));
this.showToast(t('Selected data layers could not be cleared'));
return {
targetIds: [],
items: [],
+13 -9
View File
@@ -33,7 +33,7 @@ export function _initGlobalContextPanel() {
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
t('cockpit.context.toastTransitionFailedContacts'),
t('Contacts could not complete the requested transition; try again'),
).then((succeeded) => {
if (
!this.destroyed &&
@@ -62,7 +62,9 @@ export function _initGlobalContextPanel() {
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
t('cockpit.context.toastTransitionFailedMissions'),
t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => {
if (
!this.destroyed &&
@@ -109,14 +111,16 @@ export function _initGlobalContextPanel() {
this.showToast(
stats?.statusMessage ||
(stats?.status === 'zoom-in'
? t('cockpit.context.toastZoomToSearch')
: t('cockpit.context.toastInstallationsRefreshed')),
? t('Zoom in to search mapped installations')
: t('Nearby installations refreshed')),
);
return true;
}, t('cockpit.context.toastInstallationsRefreshFailed')).finally(() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
});
}, t('Nearby installations could not be refreshed; try again')).finally(
() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
},
);
});
}
+5 -6
View File
@@ -71,7 +71,7 @@ export function _handleContextLayerChange(change) {
},
{
localizedMessage: t(
'cockpit.context.toastMissionsCancelRestoreFailed',
'Space Missions cancellation could not restore the previous layer state',
),
},
),
@@ -94,18 +94,17 @@ export function _handleContextLayerChange(change) {
!this._userFacingContextNotificationTokens.has(change.notificationToken)
) {
this.showToast(
change.reason || t('cockpit.context.toastLayerUnavailable'),
change.reason ||
t('That layer is unavailable in the current Context mode'),
);
}
this._syncContextModeButtons();
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = t('cockpit.context.toastLayerLifecycleFailed', {
const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled
? t('cockpit.context.actionStart')
: t('cockpit.context.actionStop'),
action: change.enabled ? t('start') : t('stop'),
});
// A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager
+9 -6
View File
@@ -125,12 +125,15 @@ export function connectContextManager(manager) {
restoreError,
);
}
return t('cockpit.context.toastStartBlocked', {
mode:
entryMode === 'space-missions'
? t('cockpit.context.modeSpaceMissions')
: t('cockpit.context.modeContext'),
});
return t(
'{mode} could not start because another layer did not stop cleanly',
{
mode:
entryMode === 'space-missions'
? t('Space Missions')
: t('Context'),
},
);
} finally {
if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.delete(
+54 -56
View File
@@ -5,49 +5,45 @@ export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { createWeatherPanel } from './weatherPanel.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
// Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
// hooks, CSS feed-* classes); only the final label value translates, at this
// presentation boundary. See docs/TRANSLATORS.md keep-English boundary.
const FEED_STATE_LABEL_KEYS = Object.freeze({
nominal: 'layers.status.on',
loading: 'layers.status.loading',
degraded: 'layers.status.degraded',
stale: 'layers.status.stale',
partial: 'layers.status.partial',
fallback: 'layers.status.fallback',
unavailable: 'layers.status.unavailable',
nominal: msg('ON'),
loading: msg('LOADING'),
degraded: msg('DEGRADED'),
stale: msg('STALE'),
partial: msg('PARTIAL'),
fallback: msg('FALLBACK'),
unavailable: msg('UNAVAILABLE'),
});
// Layer display names, localized at the same presentation boundary. Registry
// ids stay English; a layer without an entry here renders its module name,
// falling back to the English panel override when one exists.
const LAYER_NAME_KEYS = Object.freeze({
flights: 'layers.name.liveFlights',
military: 'layers.name.militaryFlights',
earthquakes: 'layers.name.earthquakes',
satellites: 'layers.name.satellites',
'rocket-launches': 'layers.name.rocketLaunches',
traffic: 'layers.name.traffic',
cctv: 'layers.name.cctv',
radio: 'layers.name.radio',
bikeshare: 'layers.name.bikeshare',
'ais-live-vessels': 'layers.name.aisVessels',
'military-installations': 'layers.name.installations',
'military-awareness': 'layers.name.globalContext',
'local-datacenters': 'layers.name.datacenters',
'local-dams': 'layers.name.dams',
'telegeography-submarine-cables': 'layers.name.submarineCables',
'local-firms': 'layers.name.fires',
flights: msg('Live Flights'),
military: msg('Military Flights'),
earthquakes: msg('Earthquakes (24h)'),
satellites: msg('Satellites'),
'rocket-launches': msg('Space Missions (30d)'),
traffic: msg('Street Traffic'),
cctv: msg('CCTV'),
radio: msg('Radio'),
bikeshare: msg('Bikeshare'),
'ais-live-vessels': msg('Live AIS Vessels'),
'military-installations': msg('Mapped Installations'),
'military-awareness': msg('Global Context'),
'local-datacenters': msg('Datacenters'),
'local-dams': msg('Dams'),
'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': msg('FIRMS Active Fires'),
});
function lifecycleStateLabel(lifecycleState) {
return t(
lifecycleState === 'enabling'
? 'layers.status.enabling'
: 'layers.status.disabling',
);
return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
}
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
@@ -149,7 +145,9 @@ export function layerKeyRequirementTooltip(layer = {}) {
const envVars = requiresKeyId
? keySetupRequirementEnvVars(requiresKeyId)
: null;
return envVars ? t('setup.keySetup.requirement', { envVars }) : '';
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
}
/** Layer row presentation over supplied state and actions; no layer imports. */
@@ -575,26 +573,29 @@ export class LayerPanel {
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return t('layers.meta.transitioning', {
return t('{state} · {source}', {
state: lifecycleStateLabel(lifecycleState),
source,
});
}
if (layer.lifecycleUncertain) {
return t('layers.meta.uncertainLifecycle', { source });
return t(
'UNCERTAIN · {source} · lifecycle state requires reconciliation',
{ source },
);
}
const presentedError =
stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('layers.meta.stateSourceRetry', {
return t('{state} · {source} · {detail} · retry {seconds}s', {
state: stateLabel,
source,
detail: presentedError,
seconds: stats.retryInSec,
});
}
return t('layers.meta.stateSourceDetail', {
return t('{state} · {source} · {detail}', {
state: stateLabel,
source,
detail: presentedError,
@@ -609,22 +610,20 @@ export class LayerPanel {
) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate
? this._timeAgo(stats.lastUpdate)
: t('layers.meta.never');
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
if (stats.loading) {
const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: t('layers.meta.loading');
return t('layers.meta.sourceLoading', { source, loadingLabel });
: t('loading...');
return t('{source} · {loadingLabel}', { source, loadingLabel });
}
if (feedState === 'fallback') {
const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: stats.coverage || ago;
return t('layers.meta.stateSourceDetail', {
return t('{state} · {source} · {detail}', {
state: stateLabel,
source,
detail,
@@ -637,12 +636,12 @@ export class LayerPanel {
Number.isInteger(rawRowCount) &&
acceptedRowCount >= 0 &&
rawRowCount > acceptedRowCount
? t('layers.meta.partialCounts', {
? t('{accepted} of {raw} records accepted', {
accepted: acceptedRowCount,
raw: rawRowCount,
})
: t('layers.meta.partialIncomplete');
return t('layers.meta.partialDetail', {
: t('incomplete snapshot');
return t('{state} · {source} · {detail} · {ago}', {
state: stateLabel,
source,
detail,
@@ -651,26 +650,26 @@ export class LayerPanel {
}
if (feedState === 'stale') {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('layers.meta.stateSourceAgoRetry', {
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
state: stateLabel,
source,
ago,
seconds: stats.retryInSec,
});
}
return t('layers.meta.stateSourceAgo', {
return t('{state} · {source} · {ago}', {
state: stateLabel,
source,
ago,
});
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return t('layers.meta.sourceLoading', {
return t('{source} · {loadingLabel}', {
source,
loadingLabel: stats.loadingLabel.trim(),
});
}
return t('layers.meta.sourceAgo', { source, ago });
return t('{source} · {ago}', { source, ago });
}
_syncToggleButton(button, layer) {
@@ -712,18 +711,18 @@ export class LayerPanel {
button.textContent = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? t('layers.status.uncertain')
? t('UNCERTAIN')
: layer.enabled || unavailable
? t(FEED_STATE_LABEL_KEYS[feedState])
: t('layers.status.off');
: t('OFF');
const keyGuidance = unavailable
? String(layer.stats.error || t('layers.meta.dataSourceUnavailable'))
? String(layer.stats.error || t('Data source unavailable'))
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
button.title = keyGuidance;
const ariaLabel = t('layers.meta.toggleAriaLabel', {
const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer),
state: button.textContent,
});
@@ -740,10 +739,9 @@ export class LayerPanel {
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return t('layers.meta.justNow');
if (diff < 60) return t('layers.meta.secondsAgo', { count: diff });
if (diff < 3600)
return t('layers.meta.minutesAgo', { count: Math.floor(diff / 60) });
return t('layers.meta.hoursAgo', { count: Math.floor(diff / 3600) });
if (diff < 5) return t('just now');
if (diff < 60) return t('{count}s ago', { count: diff });
if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
return t('{count}h ago', { count: Math.floor(diff / 3600) });
}
}
+4 -8
View File
@@ -151,16 +151,12 @@ export class LocationControls {
// localized static readout (resolved place names stay verbatim data).
this.elements.statusCity.textContent =
lines.city === LOCATION_MINI_EMPTY.city
? t('cockpit.location.miniCityInitial')
? t('📍 Location: --')
: lines.city;
if (lines.poi === LOCATION_MINI_EMPTY.poi) {
this.elements.statusPoi.textContent = t(
'cockpit.location.miniPoiInitial',
);
this.elements.statusPoi.textContent = t('Landmark: --');
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) {
this.elements.statusPoi.textContent = t(
'cockpit.location.miniSearchedPlaceholder',
);
this.elements.statusPoi.textContent = t('Searched location');
} else {
this.elements.statusPoi.textContent = lines.poi;
}
@@ -173,7 +169,7 @@ export class LocationControls {
const icon = this.doc.createElement('span');
icon.className = 'orbit-icon';
icon.textContent = '↻';
this.orbitIndicator.append(icon, ` ${t('cockpit.location.orbitLabel')}`);
this.orbitIndicator.append(icon, ` ${t('ORBIT')}`);
this.doc.body.appendChild(this.orbitIndicator);
}
return this.orbitIndicator;
+7 -8
View File
@@ -89,9 +89,8 @@ export class LocationNavigation {
this._collapsePOIRow();
this._updateLocationMiniStatus();
} else if (change.type === 'missing')
this._showToast(t('cockpit.location.toastNotFound'));
else if (change.type === 'failed')
this._showToast(t('cockpit.location.toastSearchFailed'));
this._showToast(t('Location not found'));
else if (change.type === 'failed') this._showToast(t('Search failed'));
else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation);
else if (
@@ -280,7 +279,7 @@ export class LocationNavigation {
_toggleOrbit() {
if (!this._currentTarget) {
this._showToast(t('cockpit.location.toastFlyToPoiFirst'));
this._showToast(t('Fly to a POI first'));
return;
}
@@ -382,11 +381,11 @@ export class LocationNavigation {
};
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('cockpit.hud.resetGlobeAria'),
t('Reset to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('cockpit.hud.resetGlobeCockpitAria'),
t('Reset cockpit to full globe view'),
);
this._globeResetPromise = null;
this._cancelGlobeReset = null;
@@ -402,11 +401,11 @@ export class LocationNavigation {
}, 4200);
this._resetGlobeBtn?.setAttribute(
'aria-label',
t('cockpit.hud.resettingGlobeAria'),
t('Resetting to full globe view'),
);
this._cockpitResetGlobeBtn?.setAttribute(
'aria-label',
t('cockpit.hud.resettingGlobeCockpitAria'),
t('Resetting cockpit to full globe view'),
);
const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false),
+1 -3
View File
@@ -29,9 +29,7 @@ export function createMapSourceControls({
statusElement.textContent =
state.status === 'switching'
? '...'
: stack?.shortLabel ||
stack?.label ||
t('cockpit.presets.mapStackFallback');
: stack?.shortLabel || stack?.label || t('MAP');
statusElement.classList.toggle('warn', !!state.lastError);
}
}
+10 -12
View File
@@ -1,5 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
@@ -311,26 +311,24 @@ export class PanelChrome {
const panelName =
panelEl
.querySelector('.panel-title, .pp-header-label')
?.textContent?.trim() || t('cockpit.panel.fallbackName');
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? 'cockpit.panel.expandTitle'
: 'cockpit.panel.collapseTitle';
? msg('Expand {name}')
: msg('Collapse {name}');
btn.title = t(titleKey, { name: panelName });
btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
if (panelEl.id === 'radio-panel') {
// Expand state reuses the layers.* keys seeded on the static button;
// the collapse state is runtime-only, so it owns cockpit.* keys.
btn.title = t(
collapsed
? 'layers.radio.expandTitle'
: 'cockpit.panel.radioCollapseTitle',
collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
);
btn.setAttribute(
'aria-label',
t(
collapsed
? 'layers.radio.expandAriaLabel'
: 'cockpit.panel.radioCollapseAria',
? msg('Expand Radio section')
: msg('Collapse Radio section'),
),
);
}
@@ -342,10 +340,10 @@ export class PanelChrome {
const panelName =
panelEl
.querySelector('.panel-title, .location-toolbar-label')
?.textContent?.trim() || t('cockpit.panel.fallbackName');
?.textContent?.trim() || t('panel');
const titleKey = collapsed
? 'cockpit.panel.expandTitle'
: 'cockpit.panel.collapseTitle';
? msg('Expand {name}')
: msg('Collapse {name}');
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = t(titleKey, { name: panelName });
+3 -1
View File
@@ -220,7 +220,9 @@ export class PanelPositionControls {
key.startsWith('godsEyeView.v6.panelPos.'),
);
if (hadOldPositions) {
this._showToast(t('cockpit.panel.layoutResetToast'));
this._showToast(
t('Panel layout updated — positions reset to new defaults'),
);
}
} catch {
// storage unavailable
+17 -21
View File
@@ -1,4 +1,4 @@
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import {
radioTunerSlot,
radioTunerCommitSlot,
@@ -53,14 +53,14 @@ export function bindRadioControls() {
'aria-label',
t(
displayOpen
? 'cockpit.utility.displayToggleCollapseAriaLabel'
: 'cockpit.utility.displayToggleAriaLabel',
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
),
);
this._cockpitDisplayToggleBtn.title = t(
displayOpen
? 'cockpit.utility.displayToggleCollapseAriaLabel'
: 'cockpit.utility.displayToggleAriaLabel',
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
);
}
if (this._cockpitRadioToggleBtn) {
@@ -69,14 +69,14 @@ export function bindRadioControls() {
'aria-label',
t(
radioOpen
? 'cockpit.utility.radioToggleCollapseAriaLabel'
: 'cockpit.utility.radioToggleAriaLabel',
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
),
);
this._cockpitRadioToggleBtn.title = t(
radioOpen
? 'cockpit.utility.radioToggleCollapseAriaLabel'
: 'cockpit.utility.radioToggleAriaLabel',
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
);
}
if (!expanded && returnFocus) {
@@ -155,21 +155,21 @@ export function bindRadioControls() {
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
: t('cockpit.radio.tunerNoStations');
: t('NO STATIONS');
}
if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || t('layers.radio.noStationAvailable');
station?.name || t('NO STATION AVAILABLE');
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute(
'aria-valuetext',
station
? t('cockpit.radio.tunerStationAria', {
? t('{name}, station {index} of {total}', {
name: station.name,
index: slot.stationIndex + 1,
total: this._radioTunerStations.length,
})
: t('cockpit.radio.tunerNoStationAria'),
: t('No station available'),
);
}
if (syncStatic)
@@ -320,11 +320,9 @@ export function bindRadioControls() {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue)
this._radioTunerValue.textContent = t('cockpit.radio.tunerOffAir');
this._radioTunerValue.textContent = t('OFF AIR');
if (this._radioTunerStation)
this._radioTunerStation.textContent = t(
'cockpit.radio.tunerStationUnavailable',
);
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
'Station unavailable after directory refresh',
@@ -367,10 +365,8 @@ export function bindRadioControls() {
// (identical value under 'en').
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
{
localizedMessage: t('cockpit.radio.toastLifecycleFailed', {
action: enabling
? t('cockpit.context.actionStart')
: t('cockpit.context.actionStop'),
localizedMessage: t('Radio could not {action} cleanly', {
action: enabling ? t('start') : t('stop'),
}),
},
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
@@ -201,8 +201,8 @@ export class RadioControls {
);
const launcherLabel = t(
compactOpen
? 'cockpit.context.radioToggleCloseAriaLabel'
: 'cockpit.context.radioToggleAriaLabel',
? msg('Close compact Radio controls')
: msg('Open compact Radio controls'),
);
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
this._contextRadioToggleBtn.title = launcherLabel;
+49 -58
View File
@@ -35,29 +35,27 @@ export function renderRadioState(state) {
if (this._radioLayerState) {
// Lifecycle enum values map to status-chip keys; counts stay machine.
const lifecycleLabel =
lifecycleState === 'enabling'
? t('layers.status.enabling')
: t('layers.status.disabling');
lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
this._radioLayerState.textContent = transitioning
? lifecycleLabel
: uncertain
? t('layers.status.uncertain')
? t('UNCERTAIN')
: state.loading
? t('cockpit.radio.stateSync')
? t('SYNC')
: enabled
? `${state.filteredCount}/${state.stationCount}`
: t('layers.status.off');
: t('OFF');
}
const enableLabel = uncertain
? t('layers.radio.reconcile')
? t('RECONCILE')
: enabled
? t('layers.radio.disable')
: t('layers.radio.enable');
? t('DISABLE')
: t('ENABLE');
const enableAria = uncertain
? t('layers.radio.reconcileAria')
? t('Reconcile Radio — lifecycle uncertain')
: enabled
? t('layers.radio.disableAria')
: t('layers.radio.enableAria');
? t('Disable Radio')
: t('Enable Radio');
if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
@@ -146,8 +144,8 @@ export function renderRadioState(state) {
);
this._radioTunerBandLabel.textContent =
state.filter === 'all'
? t('layers.radio.bandLabel')
: t('cockpit.radio.tunerCategoryBand', {
? t('DIRECTORY BAND')
: t('{category} BAND', {
category: String(
activeCategory?.label || state.filter,
).toUpperCase(),
@@ -170,7 +168,7 @@ export function renderRadioState(state) {
if (this._radioStationName)
this._radioStationName.textContent =
selected?.name || t('layers.radio.noStation');
selected?.name || t('NO STATION SELECTED');
if (this._radioStationMeta) {
const place = selected
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
@@ -182,15 +180,15 @@ export function renderRadioState(state) {
: '';
this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') ||
t('layers.radio.meta.metadataOnly')
t('Directory metadata only')
: state.loading
? t('layers.radio.meta.loading')
: t('layers.radio.meta.chooseHint');
? t('Loading station directory…')
: t('Choose a globe marker or use next.');
}
if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length
? t('layers.radio.tags', { tags: tags.join(' · ') })
? t('TAGS · {tags}', { tags: tags.join(' · ') })
: '';
}
if (this._radioStationHomepage) {
@@ -213,14 +211,12 @@ export function renderRadioState(state) {
if (this._cockpitRadioNextBtn)
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
const playbackAction = activePlayback
? t('cockpit.radio.actionPause')
? t('Pause')
: state.audioState === 'paused'
? t('cockpit.radio.actionResume')
: t('cockpit.radio.actionPlay');
const playTargetWord = selected
? t('cockpit.radio.targetSelected')
: t('cockpit.radio.targetNearest');
const playAria = t('cockpit.radio.playStateAria', {
? t('Resume')
: t('Play');
const playTargetWord = selected ? t('selected') : t('nearest');
const playAria = t('{action} {target} radio station', {
action: playbackAction,
target: playTargetWord,
});
@@ -277,65 +273,60 @@ export function renderRadioState(state) {
}
if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain
? t('cockpit.radio.miniStateUncertain')
? t('RADIO STATE UNCERTAIN')
: selected?.name ||
(state.loading
? t('cockpit.radio.miniSyncingDirectory')
: t('cockpit.context.radioMiniReady'));
(state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
}
if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain
? t('layers.status.uncertain')
: selected?.name ||
(state.loading
? t('cockpit.radio.stationSyncing')
: t('cockpit.radio.station.ready'));
? t('UNCERTAIN')
: selected?.name || (state.loading ? t('SYNCING') : t('READY'));
}
if (this._radioPlaybackState) {
const stationName = selected?.name || t('cockpit.radio.stationFallback');
const stationName = selected?.name || t('station');
const catalogSuffix = state.degraded
? state.stale
? t('layers.radio.state.staleDegradedDirectory')
: t('layers.radio.state.degradedDirectory')
? t(' · stale/degraded directory')
: t(' · degraded directory')
: state.stale
? t('layers.radio.state.staleDirectory')
? t(' · stale directory')
: '';
const outsideFilter =
selected && state.selectedIndex < 0
? t('layers.radio.state.outsideFilter')
: '';
selected && state.selectedIndex < 0 ? t(' · outside current filter') : '';
const messages = {
stopped: enabled
? t('layers.radio.state.ready')
: t('layers.radio.playbackOff'),
loading: t('layers.radio.state.loading'),
buffering: t('layers.radio.state.buffering'),
playing: t('layers.radio.state.playing', { station: stationName }),
paused: t('layers.radio.state.paused', { station: stationName }),
error: state.audioError || t('layers.radio.state.error'),
? t('Ready — playback starts only from your action')
: t('Radio off'),
loading: t('Connecting directly to broadcaster…'),
buffering: t('Buffering broadcaster stream…'),
playing: t('Playing {station}', { station: stationName }),
paused: t('Paused {station}', { station: stationName }),
error: state.audioError || t('Broadcaster stream unavailable'),
};
const voiceSuffix = state.voiceDucked
? t('layers.radio.state.voiceMuted')
? t(' · muted during voice interaction')
: state.voiceRestoring
? t('layers.radio.state.voiceRestoring')
? t(' · restoring volume after voice')
: '';
const tuningSuffix = state.tuningAwaitingStationId
? state.audioState === 'error'
? t('layers.radio.state.staticNoAudio')
: t('layers.radio.state.tuningStatic')
? t(' · static indicates no broadcaster audio')
: t(' · tuning static until broadcaster starts')
: '';
const unavailable = state.tuningUnavailableStationId
? t('layers.radio.state.stationUnavailable')
? t(
'Station unavailable after directory refresh — choose another channel',
)
: null;
const lifecycleMessage = transitioning
? lifecycleState === 'enabling'
? t('layers.radio.state.enabling')
: t('layers.radio.state.disabling')
? t('Radio is enabling…')
: t('Radio is disabling…')
: null;
const uncertainMessage = uncertain
? t('layers.radio.state.uncertain')
? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
: null;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('cockpit.radio.playbackReadyFallback')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || t('Ready')}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
this._radioPlaybackState.classList.toggle(
'error',
Boolean(
+3 -5
View File
@@ -58,7 +58,7 @@ export class SceneControls {
if (!this.destroyed) elements.file.value = '';
});
if (!subscribe) {
this.updateStatus(t('setup.scenes.statusReady'));
this.updateStatus(t('Ready'));
this.setProgress(0);
this.setButtons(false);
}
@@ -124,7 +124,7 @@ export class SceneControls {
const generation = ++this.actionGeneration;
const failed = () => {
if (!this.destroyed && generation === this.actionGeneration)
this.updateStatus(t('setup.scenes.status.actionFailed'));
this.updateStatus(t('Scene action failed'));
};
try {
const result = this.actions[action](...args);
@@ -163,9 +163,7 @@ export class SceneControls {
const shot = scene?.shots.find((item) => item.id === shotId);
if (
shot &&
window.confirm(
t('setup.scenes.status.deleteShotConfirm', { shot: shot.title }),
)
window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
)
this.run('deleteShot', sceneId, shotId);
}
+9 -9
View File
@@ -189,12 +189,10 @@ export class ShareRestoration {
if (result.classification === 'pending') {
this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = trackingKey;
this.showStatus(t('cockpit.status.acquiring'), {
this.showStatus(t('ACQUIRING'), {
state: 'acquiring',
detail: t('cockpit.status.sharedSubjectDetail', {
subject: String(
result.label || t('cockpit.status.subjectFallback'),
).toUpperCase(),
detail: t('SHARED {subject}', {
subject: String(result.label || t('entity')).toUpperCase(),
}),
persistent: true,
});
@@ -219,13 +217,15 @@ export class ShareRestoration {
const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration;
const subject = result.label || t('cockpit.status.subjectFallback');
const subject = result.label || t('entity');
const message =
result.classification === 'expired'
? t('cockpit.status.sharedFollowExpired', { subject })
? t('Shared {subject} follow expired', { subject })
: result.classification === 'source-unavailable'
? t('cockpit.status.sharedRestoreFailed', { subject })
: t('cockpit.status.sharedUnavailable', { subject });
? t('Shared {subject} could not be restored — feed unavailable', {
subject,
})
: t('Shared {subject} is unavailable', { subject });
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
+14 -14
View File
@@ -9,7 +9,7 @@ import {
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { t } from '../i18n/index.js';
import { t, msg } from '../i18n/index.js';
// loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so
@@ -17,17 +17,17 @@ import { t } from '../i18n/index.js';
// the closed label vocabulary to the catalog, and pass anything unrecognized
// (a live provider error) through verbatim (docs/TRANSLATORS.md).
const STATUS_LABEL_KEYS = Object.freeze({
'LOADING LIVE DATA': 'shell.status.loadingLiveData',
'LOAD COMPLETE': 'shell.status.loadComplete',
'LOAD CANCELLED': 'shell.status.loadCancelled',
'LOAD FAILED': 'shell.status.loadFailed',
'LIVE DATA OFF': 'shell.status.liveDataOff',
'MAPPED SITES LOADED': 'shell.status.mappedSitesLoaded',
'RETRYING MAPPED SITES': 'shell.status.retryingMappedSites',
'FETCHING MAPPED SITES': 'shell.status.fetchingMappedSites',
'TURNING OFF LIVE DATA': 'shell.status.turningOffLiveData',
'REFRESHING LIVE DATA': 'shell.status.refreshingLiveData',
'syncing road network': 'shell.status.trafficSyncing',
'LOADING LIVE DATA': msg('LOADING LIVE DATA'),
'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': msg('syncing road network'),
});
function localizeStatusLabel(label) {
@@ -41,8 +41,8 @@ const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
function localizeStatusDetail(detail) {
if (typeof detail !== 'string' || !detail) return detail;
const match = detail.match(RETRY_DETAIL_RE);
if (match) return t('shell.status.retryingIn', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('shell.status.retryPending');
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('retry pending');
return detail;
}
+78 -80
View File
@@ -1,5 +1,5 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label="cockpit.hud.sectionLabel">
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label>
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
@@ -61,19 +61,19 @@
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
<span data-i18n="cockpit.hud.level"></span><small>LEVEL</small><span></span>
<span></span><small data-i18n>LEVEL</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label="cockpit.hud.routeDirectionAriaLabel">
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction" data-i18n-aria-label>
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label" data-i18n="cockpit.hud.routeDirectionIdle">DEST ---°</small>
<small id="cockpit-route-direction-label" data-i18n>DEST ---°</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n="cockpit.hud.visorPlane">OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n="cockpit.hud.visorLock">VISOR LOCK · ACTIVE</div>
<div class="cockpit-visor-status cockpit-visor-status-left" data-i18n>OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right" data-i18n>VISOR LOCK · ACTIVE</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimGroundSpeed">GROUND SPEED · KTS</span>
<span class="cockpit-altitude-rim-label" data-i18n>GROUND SPEED · KTS</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span>
@@ -92,7 +92,7 @@
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
<span class="cockpit-altitude-rim-label" data-i18n="cockpit.readout.rimAltitude">ALTITUDE · FT</span>
<span class="cockpit-altitude-rim-label" data-i18n>ALTITUDE · FT</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span>
@@ -112,36 +112,36 @@
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker" data-i18n="cockpit.hud.firstPerson">FIRST PERSON</span>
<span class="cockpit-kicker" data-i18n>FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n="cockpit.hud.aircraftMetaInitial">LIVE TRACK · COURSE ALIGNED</span>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta" data-i18n>LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label="cockpit.hud.visionGroupAriaLabel">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label="cockpit.vision.previousAriaLabel" data-i18n-title="cockpit.vision.previousTitle">
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label>
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small data-i18n="cockpit.vision.previousLabel">PREV</small>
<small data-i18n>PREV</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label="cockpit.vision.currentAriaLabel" data-i18n-title="cockpit.vision.currentTitle">
<small data-i18n="cockpit.vision.current">CURRENT</small>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next" data-i18n-aria-label data-i18n-title>
<small data-i18n>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label="cockpit.vision.nextAriaLabel" data-i18n-title="cockpit.vision.nextTitle">
<small data-i18n="cockpit.vision.nextLabel">NEXT</small>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style" data-i18n-aria-label data-i18n-title>
<small data-i18n>NEXT</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label="cockpit.hud.compassAriaLabel">
<div class="cockpit-compass" aria-label="Current aircraft heading" data-i18n-aria-label>
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label" data-i18n="cockpit.readout.groundSpeed">GROUND SPEED</span>
<span class="cockpit-readout-label" data-i18n>GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label" data-i18n="cockpit.readout.altitude">ALTITUDE</span>
<span class="cockpit-readout-label" data-i18n>ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
</div>
@@ -153,26 +153,26 @@
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
</div>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label="cockpit.context.panelAriaLabel">
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject" data-i18n="cockpit.context.subjectWindow">CONTACTS · 250 KM</strong>
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label="cockpit.context.navAriaLabel">
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label="cockpit.context.previousAriaLabel" data-i18n-title="cockpit.context.previousTitle">
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label="cockpit.context.nextAriaLabel" data-i18n-title="cockpit.context.nextTitle">
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label="cockpit.context.toggleAriaLabel" data-i18n-title="cockpit.context.toggleTitle">
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier" data-i18n="cockpit.context.qualifier">CONTEXT ONLY</span>
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label="cockpit.context.cohortsAriaLabel">
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
@@ -181,22 +181,21 @@
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small data-i18n="cockpit.context.nearestLabel">NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" data-i18n="cockpit.context.nearestEmpty">NO AVAILABLE EXAMPLE</strong>
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" data-i18n>NO AVAILABLE EXAMPLE</strong>
</div>
<span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty" data-i18n="cockpit.context.uncertaintyNote">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<span id="cockpit-context-uncertainty" data-i18n>AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects"
data-i18n-aria-label="cockpit.context.weatherEnableAriaLabel" data-i18n-title="cockpit.context.weatherEnableTitle"
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
@@ -204,35 +203,35 @@
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label="cockpit.brief.carouselAriaLabel">
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label="cockpit.route.cardAriaLabel">
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n="cockpit.route.kicker">ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n="cockpit.route.statusUnavailable">ROUTE DATA UNAVAILABLE</small>
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n="cockpit.route.fromLabel">FROM</span>
<strong id="cockpit-route-from" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
<span data-i18n>FROM</span>
<strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n="cockpit.route.toLabel">TO</span>
<strong id="cockpit-route-to" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong>
<span data-i18n>TO</span>
<strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle" data-i18n="cockpit.brief.subtitle">OBSERVED / MAPPED PINGS</strong>
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label="cockpit.brief.actionsAriaLabel">
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label="cockpit.brief.previousAriaLabel" data-i18n-title="cockpit.brief.previousTitle">
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label="cockpit.brief.nextAriaLabel" data-i18n-title="cockpit.brief.nextTitle">
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
@@ -240,53 +239,52 @@
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="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."
data-i18n-title="cockpit.brief.autoTitle"
title="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." data-i18n-title
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label="cockpit.brief.collapseAriaLabel" data-i18n-title="cockpit.brief.collapseTitle">
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label="cockpit.brief.signalsAriaLabel">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label>
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label="cockpit.brief.newsAriaLabel">
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n="cockpit.brief.newsAcquiring">ACQUIRING REGIONAL NEWS</div>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label>
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n>ACQUIRING REGIONAL NEWS</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label="cockpit.brief.localAriaLabel">
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label>
<div class="cockpit-local-place">
<strong id="cockpit-local-place" data-i18n="cockpit.brief.localResolving">RESOLVING REGION</strong>
<strong id="cockpit-local-place" data-i18n>RESOLVING REGION</strong>
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div data-i18n="cockpit.brief.labelTemp"><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div data-i18n="cockpit.brief.labelWind"><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div data-i18n="cockpit.brief.labelSky"><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div data-i18n="cockpit.brief.labelPrecip"><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source" data-i18n="cockpit.brief.sourceNote">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label="cockpit.brief.tabSignalsAriaLabel">SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label="cockpit.brief.tabNewsAriaLabel">NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label="cockpit.brief.tabLocalAriaLabel">LOCAL</button>
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label>LOCAL</button>
</nav>
</footer>
</aside>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label="cockpit.utility.controlsAriaLabel">
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n="cockpit.utility.displayLabel">DISPLAY</span>
<span data-i18n>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label="cockpit.utility.displayToggleAriaLabel" data-i18n-title="cockpit.utility.displayToggleTitle">◀</button>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options" data-i18n-aria-label data-i18n-title>◀</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label="cockpit.utility.displayPanelAriaLabel">
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden data-i18n-aria-label>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
@@ -295,21 +293,21 @@
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n="cockpit.utility.radioLabel">RADIO</span>
<span data-i18n>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label="cockpit.utility.radioToggleAriaLabel" data-i18n-title="cockpit.utility.radioToggleTitle">◀</button>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls" data-i18n-aria-label data-i18n-title>◀</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label="cockpit.utility.radioPanelAriaLabel">
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n="cockpit.radio.station.ready">READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.radio.enable">ENABLE</button>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden data-i18n-aria-label>
<strong id="cockpit-radio-station" class="cockpit-radio-station" data-i18n>READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label="cockpit.radio.prevAriaLabel">◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label="cockpit.radio.playAriaLabel">▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label="cockpit.radio.nextAriaLabel">▶▶</button>
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled data-i18n-aria-label>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled data-i18n-aria-label>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n="cockpit.radio.volumeLabel">VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label="cockpit.radio.volumeAriaLabel" />
<span data-i18n>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled data-i18n-aria-label />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
@@ -318,13 +316,13 @@
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
</section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label="cockpit.exit.navAriaLabel">
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label="cockpit.exit.resetAriaLabel" data-i18n-title="cockpit.exit.resetTitle">
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label>
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n="cockpit.exit.resetLabel">RESET</span>
<span data-i18n>RESET</span>
</button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label="cockpit.exit.ariaLabel" data-i18n-title="cockpit.exit.title">
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span data-i18n="cockpit.exit.label">EXIT COCKPIT</span>
<span data-i18n>EXIT COCKPIT</span>
</button>
</nav>
+27 -32
View File
@@ -1,58 +1,53 @@
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label="shell.dock.ariaLabel">
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label="cockpit.presets.toggleAriaLabel">
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<!-- Language selector: only the group container is static (its
data-i18n-aria-label is applied from the catalog at boot). The
buttons are rendered at runtime by ui.js _initLocaleSelector(): one
per locale in the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE
pair plus the always-shipped English fallback, deduped, labels EN/ES/FR
with aria-labels from shell.locale.<code>.ariaLabel. -->
<div class="dock-locale-switch" role="group" data-i18n-aria-label="shell.locale.groupAriaLabel"></div>
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
<div class="dock-locale-switch" role="group" data-i18n-aria-label aria-label="Language"></div>
<div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label="cockpit.presets.pinAriaLabel" data-i18n-title="cockpit.presets.pinTitle"><img src="/pin.svg" alt="" /></button>
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter." data-i18n-title="cockpit.presets.styleNormalTitle">
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter." data-i18n-title>
<span class="btn-icon">◯</span>
<span class="btn-label" data-i18n="cockpit.presets.styleNormalLabel">Normal</span>
<span class="btn-label" data-i18n>Normal</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title="cockpit.presets.styleCrtTitle">
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
<span class="btn-icon">▦</span>
<span class="btn-label" data-i18n="cockpit.presets.styleCrtLabel">CRT</span>
<span class="btn-label" data-i18n>CRT</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title="cockpit.presets.styleNvgTitle">
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
<span class="btn-icon">🌙</span>
<span class="btn-label" data-i18n="cockpit.presets.styleNvgLabel">NVG</span>
<span class="btn-label" data-i18n>NVG</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title="cockpit.presets.styleFlirTitle">
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
<span class="btn-icon">🌡️</span>
<span class="btn-label" data-i18n="cockpit.presets.styleFlirLabel">FLIR</span>
<span class="btn-label" data-i18n>FLIR</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title="cockpit.presets.styleAnimeTitle">
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
<span class="btn-icon">✦</span>
<span class="btn-label" data-i18n="cockpit.presets.styleAnimeLabel">Anime</span>
<span class="btn-label" data-i18n>Anime</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading." data-i18n-title="cockpit.presets.styleNoirTitle">
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
<span class="btn-icon">◐</span>
<span class="btn-label" data-i18n="cockpit.presets.styleNoirLabel">Noir</span>
<span class="btn-label" data-i18n>Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title="cockpit.presets.styleSnowTitle">
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title>
<span class="btn-icon">❄</span>
<span class="btn-label" data-i18n="cockpit.presets.styleSnowLabel">Snow</span>
<span class="btn-label" data-i18n>Snow</span>
<span class="btn-key">7</span>
</button>
</div>
@@ -65,7 +60,7 @@
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label" data-i18n="cockpit.presets.miniStyleLabel">Style</span>
<span class="panel-mini-label" data-i18n>Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
</div>
</div>
@@ -76,26 +71,26 @@
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label="cockpit.location.toggleAria" data-i18n-title="cockpit.location.toggleAria">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title="cockpit.location.collapseTitle">+</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city" data-i18n="cockpit.location.miniCityInitial">📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi" data-i18n="cockpit.location.miniPoiInitial">Landmark: --</div>
<div id="location-mini-city" class="location-mini-city" data-i18n>📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi" data-i18n>Landmark: --</div>
</div>
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label="cockpit.location.pinAriaLabel" data-i18n-title="cockpit.location.pinTitle"><img src="/pin.svg" alt="" /></button>
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div>
<div class="location-city-row">
<div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title="cockpit.location.searchToggleTitle">
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title>
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label="cockpit.location.searchAria" data-i18n-placeholder="cockpit.location.searchPlaceholder" />
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
</div>
</div>
</div>
+52 -52
View File
@@ -28,112 +28,112 @@
<div class="panel-glow"></div>
<div class="global-context-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n="cockpit.context.panelTitle">CONTEXT</span>
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label="cockpit.context.radioToggleAriaLabel" data-i18n-title="cockpit.context.radioToggleTitle">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button>
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label="cockpit.context.radioMiniGroupAriaLabel">
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden data-i18n-aria-label>
<div class="context-radio-mini-heading">
<span data-i18n="cockpit.context.radioMiniTitle">RADIO</span>
<strong id="context-radio-mini-station" data-i18n="cockpit.context.radioMiniReady">RADIO READY</strong>
<span data-i18n>RADIO</span>
<strong id="context-radio-mini-station" data-i18n>RADIO READY</strong>
<span class="context-radio-mini-actions">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label="cockpit.context.radioDetailsAriaLabel" data-i18n-title="cockpit.context.radioDetailsTitle">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button>
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label="cockpit.context.radioCloseAriaLabel" data-i18n-title="cockpit.context.radioCloseTitle">
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</span>
</div>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="cockpit.context.radioMiniEnable">ENABLE</button>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<div class="context-radio-mini-transport">
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label="cockpit.context.radioMiniPrevAriaLabel" data-i18n-title="cockpit.context.radioMiniPrevTitle">◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label="cockpit.context.radioMiniPlayAriaLabel" data-i18n-title="cockpit.context.radioMiniPlayTitle">▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label="cockpit.context.radioMiniNextAriaLabel" data-i18n-title="cockpit.context.radioMiniNextTitle">▶▶</button>
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled data-i18n-aria-label data-i18n-title>◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled data-i18n-aria-label data-i18n-title>▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled data-i18n-aria-label data-i18n-title>▶▶</button>
</div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span data-i18n="cockpit.context.radioMiniVolumeLabel">VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label="cockpit.context.radioMiniVolumeAriaLabel" />
<span data-i18n>VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled data-i18n-aria-label />
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label>
</div>
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title="cockpit.context.collapseTitle">▶</button>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label="cockpit.context.modesAriaLabel">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label="cockpit.context.contactsTabAriaLabel" data-i18n-title="cockpit.context.contactsTabTitle">
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span data-i18n="cockpit.context.contactsTabLabel">CONTACTS</span>
<span data-i18n>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span data-i18n="cockpit.context.missionsTabLabel">SPACE MISSIONS</span>
<span data-i18n>SPACE MISSIONS</span>
</button>
</div>
<div id="context-mode-standby" class="context-mode-standby">
<strong data-i18n="cockpit.context.standbyTitle">SELECT CONTEXT</strong>
<strong data-i18n>SELECT CONTEXT</strong>
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label="cockpit.context.actionsAriaLabel">
<div class="global-context-actions" aria-label="Contact Context actions" data-i18n-aria-label>
<button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span data-i18n="cockpit.context.cockpitEntryLabel">COCKPIT</span>
<span data-i18n>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n="cockpit.context.searchNearbyLabel">SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label="cockpit.context.tr3bAriaLabel" data-i18n-title="cockpit.context.tr3bTitle">🛸</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
</div>
<aside id="military-awareness-panel">
<div class="military-awareness-standby">
<strong data-i18n="cockpit.context.awarenessOffTitle">CONTACTS CONTEXT OFF</strong>
<span data-i18n="cockpit.context.awarenessOffHint">SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
</aside>
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label="cockpit.context.rosterAriaLabel">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions" data-i18n-aria-label>
<div class="space-mission-roster-header">
<div>
<strong data-i18n="cockpit.context.rosterTitle">AVAILABLE MISSIONS</strong>
<span data-i18n="cockpit.context.rosterHint">SELECT A MISSION TO INSPECT</span>
<strong data-i18n>AVAILABLE MISSIONS</strong>
<span data-i18n>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>—</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty" data-i18n="cockpit.context.rosterLoading">LOADING 30-DAY MISSION INDEX</div>
<div class="space-mission-roster-empty" data-i18n>LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n="layers.missions.roster.keyboardHint">TAB PREVIEWS · ENTER / SPACE SELECTS</p>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation data-i18n>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</div>
</section>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label="layers.radio.panelAriaLabel">
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n="layers.radio.panelTitle">RADIO</span>
<span class="panel-title" data-i18n>RADIO</span>
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title="layers.radio.expandTitle" data-i18n-aria-label="layers.radio.expandAriaLabel">+</button>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section" data-i18n-title data-i18n-aria-label>+</button>
</div>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n="layers.radio.enable">ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n="layers.radio.stationTagLabel">STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label="layers.radio.filterAriaLabel">
<option value="all" data-i18n="layers.radio.filterAll">All</option>
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
<strong id="radio-station-name" data-i18n="layers.radio.noStation">NO STATION SELECTED</strong>
<span id="radio-station-meta" data-i18n="layers.radio.stationHint">Enable Radio, then choose a globe marker or use next.</span>
<strong id="radio-station-name" data-i18n>NO STATION SELECTED</strong>
<span id="radio-station-meta" data-i18n>Enable Radio, then choose a globe marker or use next.</span>
<span id="radio-station-tags"></span>
</div>
<div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n="layers.radio.dragToTune">DRAG TO TUNE</output>
<output id="radio-tuner-value" for="radio-tuner-slider" data-i18n>DRAG TO TUNE</output>
</div>
<div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div>
@@ -142,27 +142,27 @@
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station" data-i18n="layers.radio.tunerIdle">ALL · DRAG THE NEEDLE</span>
<span data-i18n="layers.radio.snapsNote">SNAPS TO AVAILABLE STATIONS</span>
<span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
<span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
</div>
</div>
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label="layers.radio.transportAriaLabel">
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label="layers.radio.prevAriaLabel" data-i18n="layers.radio.prev">PREV</button>
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label="layers.radio.playAriaLabel" data-i18n="layers.radio.play">PLAY</button>
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label="layers.radio.nextAriaLabel" data-i18n="layers.radio.next">NEXT</button>
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label="layers.radio.stopAriaLabel" data-i18n="layers.radio.stop">STOP</button>
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label>
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled data-i18n-aria-label data-i18n>PREV</button>
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled data-i18n-aria-label data-i18n>PLAY</button>
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled data-i18n-aria-label data-i18n>NEXT</button>
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label data-i18n>STOP</button>
</div>
<label class="radio-volume-row" for="radio-volume">
<span data-i18n="layers.radio.volumeLabel">VOLUME</span>
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label="layers.radio.volumeAriaLabel" />
<span data-i18n>VOLUME</span>
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" data-i18n-aria-label />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n="layers.radio.playbackOff">Radio off</div>
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite" data-i18n>Radio off</div>
<div class="radio-links">
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n="layers.radio.stationSite">STATION SITE</a>
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy" data-i18n="layers.radio.privacyNote">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
<div class="sdr-radio-heading">
+41 -41
View File
@@ -3,20 +3,20 @@
<div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title="cockpit.display.collapseTitle">&#x25C0;</button>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>&#x25C0;</button>
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title="cockpit.display.hudToggleTitle">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.hudLayoutLabel">Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label="cockpit.display.hudLayoutAriaLabel">
<option value="tactical" data-i18n="cockpit.display.hudLayoutTactical">Tactical</option>
<option value="operator" data-i18n="cockpit.display.hudLayoutOperator">Operator</option>
<option value="minimal" data-i18n="cockpit.display.hudLayoutMinimal">Minimal</option>
<span class="pp-slider-mini-label" data-i18n>Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n>Minimal</option>
<option value="cyber">Cyber</option>
</select>
</div>
@@ -52,39 +52,39 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title="cockpit.display.detectionToggleTitle" data-i18n-aria-label="cockpit.display.detectionAriaLabel">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title data-i18n-aria-label>
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label" data-i18n="cockpit.display.detectionLabel">DETECT</span>
<span class="pp-label" data-i18n>DETECT</span>
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n="cockpit.display.densityLabel">Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label="cockpit.display.densityAriaLabel" />
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.allocationLabel">Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label="cockpit.display.allocationAriaLabel">
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n="cockpit.display.allocationElastic">Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n="cockpit.display.allocationWeighted">Weighted</button>
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n="cockpit.display.fadeLabel">Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label="cockpit.display.fadeAriaLabel" data-i18n-title="cockpit.display.fadeTitle" />
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n="cockpit.display.outsideLabel">Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label="cockpit.display.outsideAriaLabel" data-i18n-title="cockpit.display.outsideTitle" />
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel">
<div class="param-panel-header">
<span class="param-panel-title" data-i18n="cockpit.display.parametersTitle">PARAMETERS</span>
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title="cockpit.display.parametersCollapseTitle">−</button>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel" data-i18n-title>−</button>
</div>
<div id="param-sliders"></div>
</div>
@@ -93,31 +93,31 @@
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title="cockpit.display.modelsToggleTitle">
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.modelsLabel">Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label="cockpit.display.modelsCoverageAriaLabel">
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n="cockpit.display.modelsModeProximity">Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n="cockpit.display.modelsModeAll">All</button>
<span class="pp-slider-mini-label" data-i18n>Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
</div>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title="cockpit.display.scopeToggleTitle">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label" data-i18n="cockpit.display.scopeLabel">Scope</span>
<span class="pp-label" data-i18n>Scope</span>
</button>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.featherLabel">Feather</span>
<span class="pp-slider-mini-label" data-i18n>Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label="cockpit.display.featherAria" data-i18n-title="cockpit.display.featherTitle" />
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
@@ -148,35 +148,35 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title="cockpit.display.celestialToggleTitle">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label" data-i18n="cockpit.display.celestialLabel">Celestial</span>
<span class="pp-label" data-i18n>Celestial</span>
</button>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title="cockpit.display.cleanViewToggleTitle">
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title>
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label" data-i18n="cockpit.display.cleanViewLabel">Clean UI</span>
<span class="pp-label" data-i18n>Clean UI</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow" data-i18n-title="cockpit.display.bloomToggleTitle">
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow" data-i18n-title>
<span class="pp-icon">&#x2728;</span>
<span class="pp-label" data-i18n="cockpit.display.bloomLabel">Bloom</span>
<span class="pp-label" data-i18n>Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label="cockpit.display.bloomAria" />
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening" data-i18n-title="cockpit.display.sharpenToggleTitle">
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening" data-i18n-title>
<span class="pp-icon">&#x1F50D;</span>
<span class="pp-label" data-i18n="cockpit.display.sharpenLabel">Sharpen</span>
<span class="pp-label" data-i18n>Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label="cockpit.display.sharpenAria" />
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls" data-i18n-title="cockpit.display.cleanViewExitTitle" data-i18n="cockpit.display.cleanViewExitLabel">EXIT CLEAN VIEW</button>
<button id="clean-view-exit" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
+1 -1
View File
@@ -6,6 +6,6 @@
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n="shell.loading.initialStatus">Initializing photorealistic world...</p>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
+43 -43
View File
@@ -4,9 +4,9 @@
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n="shell.panels.dataLayers">DATA LAYERS</span>
<span class="panel-title" data-i18n>DATA LAYERS</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title="shell.panels.collapseTitle">+</button>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div id="data-toggles" class="data-toggle-list"></div>
</div>
@@ -17,56 +17,56 @@
<div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n="layers.cctv.panelTitle">CCTV</span>
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title="layers.cctv.collapseTitle">+</button>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n="layers.cctv.sourceUnknown">SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n="layers.cctv.metaIdle">Enable CCTV to load camera intersections</div>
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn" data-i18n="layers.cctv.toggleOff">CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n="layers.cctv.nearest">NEAREST</button>
<button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn" data-i18n="layers.cctv.prev">PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label="layers.cctv.cameraAriaLabel"></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n="layers.cctv.next">NEXT</button>
<button id="cctv-prev-btn" class="scene-btn" data-i18n>PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn" data-i18n="layers.cctv.focus">FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n="layers.cctv.coverageOff">COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n="layers.cctv.autoHopOff">AUTO HOP OFF</button>
<button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n>COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn" data-i18n="layers.cctv.projectionOn">PROJECTION ON</button>
<button id="cctv-projection-btn" class="scene-btn" data-i18n>PROJECTION ON</button>
<span id="cctv-quality-chip">CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n="layers.cctv.calibrationLabel">CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title="layers.cctv.adjustTitle" data-i18n="layers.cctv.adjustLabel">ADJUST</button>
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title data-i18n>ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label="layers.cctv.calPoseAriaLabel">
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title="layers.cctv.calHeadingTitle">HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title="layers.cctv.calPitchTitle">PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title="layers.cctv.calFovTitle">FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title="layers.cctv.calRangeTitle">RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title="layers.cctv.calHeightTitle">HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calNorthTitle">ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title="layers.cctv.calEastTitle">ΔE --</button>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type" data-i18n-title>HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type" data-i18n-title>PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type" data-i18n-title>FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type" data-i18n-title>RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type" data-i18n-title>HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type" data-i18n-title>ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type" data-i18n-title>ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n="layers.cctv.saveCal">SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n="layers.cctv.resetCal">RESET CAL</button>
<button id="cctv-calib-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label" data-i18n="layers.cctv.summaryLabel">SCENE SUMMARY</div>
<div id="cctv-summary" data-i18n="layers.cctv.summaryIdle">Enable CCTV to start camera-linked intelligence summaries.</div>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
@@ -145,35 +145,35 @@
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n="setup.scenes.panelTitle">SCENES</span>
<span class="panel-title" data-i18n>SCENES</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title="setup.scenes.collapseTitle">+</button>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label="setup.scenes.recipeAriaLabel"></select>
<button id="scene-new-btn" class="scene-btn" data-i18n="setup.scenes.new">NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.delete">DEL</button>
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n>NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn" data-i18n="setup.scenes.capture">CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn" data-i18n="setup.scenes.updateShot">UPDATE SHOT</button>
<button id="scene-capture-btn" class="scene-btn" data-i18n>CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn" data-i18n="setup.scenes.start">START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n="setup.scenes.stop">STOP</button>
<button id="scene-next-btn" class="scene-btn" data-i18n="setup.scenes.next">NEXT</button>
<button id="scene-start-btn" class="scene-btn" data-i18n>START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger" data-i18n>STOP</button>
<button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn" data-i18n="setup.scenes.exportPresets">EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n="setup.scenes.import">IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n="setup.scenes.runLog">RUN LOG</button>
<button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n>RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status" data-i18n="setup.scenes.statusReady">Ready</div>
<div id="scene-status" data-i18n>Ready</div>
</div>
</div>
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more