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) # Optional: OpenSky credentials JSON file path (alternative to above)
# OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # 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 # Optional: dev server config
PORT=4173 PORT=4173
# Network binding. Default is localhost — the server is reachable ONLY from # Network binding. Default is localhost — the server is reachable ONLY from
+10 -23
View File
@@ -1,5 +1,14 @@
# Changelog # 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, - Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots. 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 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. 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 - 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 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 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 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. - 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. - 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. - 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, publicDir,
googleApiKey, googleApiKey,
cesiumToken, cesiumToken,
defaultLocale = '',
secondaryLocale = '',
mapillaryToken, mapillaryToken,
host = 'localhost', host = 'localhost',
port = 4173, port = 4173,
@@ -109,14 +107,6 @@ export function createBrowserViteConfig({
define: { define: {
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), '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). // Mapillary client tokens are public by design (see SECURITY.md).
'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify( 'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify(
mapillaryToken ?? '', 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`. > attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`.
> The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history. > The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history.
### Internationalization / catalog locales (September 2026) ### Interface localization (October 2026)
The application-owned UI is fully extracted into message catalogs; **en** Shipped locales: **en** (source and fallback) and **ru**. The code lives in
is the only shipped locale in this foundation state — additional locales `src/i18n/`; the contributor workflow is in [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
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/`:
- **File map.** `src/i18n/locale.js` owns locale resolution, guarded storage - **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
(`gev:locale:v1`), the one-shot `?lang=` override, and `<html lang>`/`<html '{count} cameras')`, and `msg('Live Flights')` for text stored in data and
dir>` metadata. `src/i18n/index.js` owns the catalog registry, `t()` translated later. Markup opts in with `data-i18n` (element text) and
(interpolation + `Intl.PluralRules` plural selection), `formatNumber` / `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
`formatDate`, `applyDocumentTranslations()`, and value). English has no catalog; every other locale is
`persistLocaleAndReload()`. The shipped catalog is four flat message maps `src/i18n/locales/<code>.json`, and a missing entry falls back to English.
— `src/i18n/locales/en/{shell,cockpit,layers,setup}.js`, 912 keys (shell - **Locale choice.** `?lang=` (one-shot) → stored choice (`gev:locale:v1`) →
34, cockpit 369, layers 388, setup 121). Plural entries are the en `navigator.languages` → English. The EN/RU switch in the Visual Presets dock
`{ one, other }` shape; a future locale may add only cardinal categories stores the choice and reloads, so nothing re-renders live.
`Intl.PluralRules` reports valid for it, and an entry lacking the selected - **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
category degrades to `other` at runtime. Locale-code normalization extracts every message and fails on missing, unused, placeholder-mismatched or
(`es-MX`/`es_419` → `es`, plus fr/ru/uk and variants) ships ahead of the wrongly-shaped plural entries.
locales on purpose: knowing a code is hygiene, shipping is catalog-driven - **Not translated by design:** layer ids, share-link and hash parameters, API
through `CATALOG_LOCALES`. fields, voice tool schemas, provider names, feed data (callsigns, vessel and
- **Selector.** The command dock carries a compact locale switch: a static station names) and attribution.
`.dock-locale-switch` group container in `index.html` whose buttons - **Gaps.** Strings added after #601's extraction (for example the Street Level
(`.dock-locale-btn`) are rendered at runtime by `src/ui/applicationShell.js` panel) are still raw English; there is no lint for unmarked UI text yet.
`_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.
## Canonical Docs Order ## 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 Shipped languages: **English** (source and fallback) and **Russian** (`ru`).
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.
## Which locale ships, and which pair is offered ## How it works
One catalog ships: **en** (source of truth and unconditional fallback). The English text is the key. There is no English catalog: the code is the source.
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.
## How the catalog system works ```js
t('Save keys'); // plain text
All application-owned UI text lives in four flat message catalogs (en t('Saved to {store}', { store }); // named placeholders
today; one directory per locale once shipped): tn(count, '{count} camera', '{count} cameras'); // count-dependent text
msg('Live Flights'); // text kept in data, translated later by t()
```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
``` ```
| Namespace | Surface | ```html
| --- | --- | <span data-i18n>Normal</span>
| `shell.*` | Document chrome: title, dock actions, panels, global status chip, sync chips, toasts, locale selector | <button title="Pin visual presets" data-i18n-title>…</button>
| `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 |
Each catalog file exports `NAMESPACE` and a default map of `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.
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).
## 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. ```json
`shell.panels.dataLayers`, `cockpit.readout.altitude`. {
- Runtime flows/states: `<namespace>.<flow>.<state>` — e.g. "Save keys": "Сохранить ключи",
`shell.status.loadingLiveData`, `layers.missions.telemetry.km`. "{count} cameras": {
- Names are semantic and stable. Never rename, reorder, or renumber an "one": "{count} камера",
existing key: `data-i18n` attributes and runtime `t()` call sites in other "few": "{count} камеры",
files reference them. "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 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.
optional — the gates below fail the build if a step is skipped.
1. **Catalogs.** Copy `src/i18n/locales/en/` to ## The check
`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
```sh ```sh
node --test src/i18n/ # 40 tests: core + pair config, catalog parity, markup coverage, repair-pass anchors npm run i18n # what is missing, unused or broken, per language
npm test # full suite (see below for the known environmental caveat) 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, `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.
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.
Full-suite caveat: `src/devFreshDotenv.test.mjs`'s external-keys provenance Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
test fails when a provider key (e.g. `OPENAI_API_KEY`) is exported in the
running shell — an environmental false positive unrelated to i18n. ## 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", "doctor": "node scripts/setup-doctor.mjs",
"format": "node scripts/format.mjs --write", "format": "node scripts/format.mjs --write",
"format:check": "node scripts/format.mjs --check", "format:check": "node scripts/format.mjs --check",
"i18n": "node scripts/i18n.mjs",
"layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs",
"layer-token:check": "node scripts/check-layer-state-tokens.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs",
"dev": "vite", "dev": "vite",
+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/localRequestGate.mjs",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -448,10 +445,7 @@
"src/data/layerSnapshot.js", "src/data/layerSnapshot.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -465,10 +459,7 @@
"src/app/stateChannel.js", "src/app/stateChannel.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -482,10 +473,7 @@
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -501,10 +489,7 @@
"src/ui/cctvPresentation.js", "src/ui/cctvPresentation.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -525,10 +510,7 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -559,10 +541,7 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -574,10 +553,7 @@
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -719,10 +695,7 @@
"src/data/cameraArrival.js", "src/data/cameraArrival.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -831,10 +804,7 @@
"src/data/cameraArrival.js", "src/data/cameraArrival.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -891,10 +861,7 @@
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -936,10 +903,7 @@
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -995,10 +959,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1047,10 +1008,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1079,10 +1037,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
}, },
@@ -1112,10 +1067,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1153,10 +1105,7 @@
"src/data/infrastructureOverlayEntry.js", "src/data/infrastructureOverlayEntry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
}, },
@@ -1186,10 +1135,7 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1233,10 +1179,7 @@
"src/layers/traffic/roadModes.js", "src/layers/traffic/roadModes.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile"] "external": ["cesium", "pbf", "@mapbox/vector-tile"]
}, },
@@ -1262,10 +1205,7 @@
"src/data/cameraSensitivity.js", "src/data/cameraSensitivity.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1309,10 +1249,7 @@
"src/layers/cctv/sourcePolicy.js", "src/layers/cctv/sourcePolicy.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js", "cesium"]
}, },
@@ -1410,10 +1347,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1433,10 +1367,7 @@
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1488,10 +1419,7 @@
"src/maps/imageryHost.js", "src/maps/imageryHost.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1541,10 +1469,7 @@
"src/voice/pointerReticle.js", "src/voice/pointerReticle.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -2351,10 +2276,7 @@
"src/app/embed.js", "src/app/embed.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -2879,10 +2801,7 @@
"src/sources/infrastructureData.js", "src/sources/infrastructureData.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -2987,10 +2906,7 @@
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -3021,10 +2937,7 @@
"src/ui/visualSettings.js", "src/ui/visualSettings.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3047,10 +2960,7 @@
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
@@ -3176,10 +3086,7 @@
"src/voice/pointerReticle.js", "src/voice/pointerReticle.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/en/cockpit.js", "src/i18n/locales/ru.json"
"src/i18n/locales/en/layers.js",
"src/i18n/locales/en/setup.js",
"src/i18n/locales/en/shell.js"
], ],
"external": [] "external": []
}, },
-2
View File
@@ -23,8 +23,6 @@ export default defineConfig(({ command, mode }) => {
], ],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY, googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN, cesiumToken: process.env.CESIUM_ION_TOKEN,
defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '',
secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '',
mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN, mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN,
host: process.env.HOST, host: process.env.HOST,
port: process.env.PORT, 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 no share link state, do default fly-to Austin
if (!styleManager.hasShareState) { if (!styleManager.hasShareState) {
loaderStatus.textContent = t('shell.loading.status.flying'); loaderStatus.textContent = t('Flying to Austin, TX...');
defer(flyToAustin(viewer)); defer(flyToAustin(viewer));
} else { } else {
loaderStatus.textContent = t('shell.loading.status.restoring'); loaderStatus.textContent = t('Restoring shared view...');
} }
return { styleManager, weatherEffects, cockpitCloudEffects }; return { styleManager, weatherEffects, cockpitCloudEffects };
+10 -7
View File
@@ -43,7 +43,7 @@ export async function createApplicationScene({
else window.__GOOGLE_MAPS_API_KEY__ = previousKey; 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. // Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div'); const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits'; creditContainer.id = 'cesium-credits';
@@ -62,8 +62,8 @@ export async function createApplicationScene({
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
loaderStatus.textContent = loaderStatus.textContent =
googleApiKey || googleTokens || cesiumToken googleApiKey || googleTokens || cesiumToken
? t('shell.loading.status.tilesGoogle') ? t('Loading Google 3D Tiles...')
: t('shell.loading.status.tilesKeyless'); : t('Loading the keyless globe...');
const photoreal = await loadPhotorealisticTileset(Cesium, { const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey, googleApiKey,
googleTokens, googleTokens,
@@ -91,14 +91,17 @@ export async function createApplicationScene({
tileError, tileError,
); );
const tileErrorDetail = describeError(tileError); const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', { loaderStatus.textContent = t(
detail: tileErrorDetail, 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...',
}); {
detail: tileErrorDetail,
},
);
} }
viewer.scene.globe.show = true; viewer.scene.globe.show = true;
} }
loaderStatus.textContent = t('shell.loading.status.systems'); loaderStatus.textContent = t('Initializing systems...');
const mapStackController = new MapController(viewer, { const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender, requestRender: governorRequestRender,
+1 -1
View File
@@ -21,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) {
expand.setAttribute('aria-expanded', 'false'); expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button'); close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0'); close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', t('shell.credits.closeAria')); close.setAttribute('aria-label', t('Close data attribution'));
if (expand.dataset.gevKeyboardReady === 'true') return true; if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true'; expand.dataset.gevKeyboardReady = 'true';
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, relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null, count: null,
nearest: [], nearest: [],
reason: t('layers.awareness.reason.feedUnavailable'), reason: t('feed unavailable'),
}; };
if (stale) if (stale)
return { return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN, relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null, count: null,
nearest: [], nearest: [],
reason: t('layers.awareness.reason.feedStale'), reason: t('feed stale'),
}; };
const normalized = (Array.isArray(items) ? items : []) const normalized = (Array.isArray(items) ? items : [])
.filter( .filter(
@@ -108,8 +108,8 @@ export function summarizeAwarenessCohort(
count: normalized.length, count: normalized.length,
nearest: normalized.slice(0, limit), nearest: normalized.slice(0, limit),
reason: normalized.length reason: normalized.length
? t('layers.awareness.reason.nearby') ? t('observed or mapped nearby context')
: t('layers.awareness.reason.none'), : 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. * 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, * 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. // translate through these keys at the presentation boundary.
const CLASS_DISPLAY_KEYS = Object.freeze({ const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({ station: Object.freeze({
label: 'layers.satellites.class.station', label: msg('STATION'),
blurb: 'layers.satellites.class.stationBlurb', blurb: msg('Crewed stations and their visiting vehicles'),
}), }),
nav: Object.freeze({ nav: Object.freeze({
label: 'layers.satellites.class.nav', label: msg('NAV'),
blurb: 'layers.satellites.class.navBlurb', blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
}), }),
geo: Object.freeze({ geo: Object.freeze({
label: 'layers.satellites.class.geo', label: msg('GEO'),
blurb: 'layers.satellites.class.geoBlurb', blurb: msg(
'Geostationary belt — comms and weather, fixed over the equator',
),
}), }),
visual: Object.freeze({ visual: Object.freeze({
label: 'layers.satellites.class.visual', label: msg('VISUAL'),
blurb: 'layers.satellites.class.visualBlurb', blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
}), }),
comms: Object.freeze({ comms: Object.freeze({
label: 'layers.satellites.class.comms', label: msg('COMMS'),
blurb: 'layers.satellites.class.commsBlurb', 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. * 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'; export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */ /** Existing selector grid size; one ambient winner is retained per cell. */
@@ -70,25 +70,25 @@ const NUMERIC_TYPE_FAMILIES = {
// machine values — TYPE_STYLES matches on them and tests pin them — so card // machine values — TYPE_STYLES matches on them and tests pin them — so card
// text translates only through this map at the presentation boundary. // text translates only through this map at the presentation boundary.
const TYPE_DISPLAY_KEYS = Object.freeze({ const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: 'layers.vessel.type.fishing', FISHING: msg('FISHING'),
TOWING: 'layers.vessel.type.towing', TOWING: msg('TOWING'),
DREDGER: 'layers.vessel.type.dredger', DREDGER: msg('DREDGER'),
'DIVE OPS': 'layers.vessel.type.diveOps', 'DIVE OPS': msg('DIVE OPS'),
MILITARY: 'layers.vessel.type.military', MILITARY: msg('MILITARY'),
SAILING: 'layers.vessel.type.sailing', SAILING: msg('SAILING'),
PLEASURE: 'layers.vessel.type.pleasure', PLEASURE: msg('PLEASURE'),
PILOT: 'layers.vessel.type.pilot', PILOT: msg('PILOT'),
SAR: 'layers.vessel.type.sar', SAR: msg('SAR'),
TUG: 'layers.vessel.type.tug', TUG: msg('TUG'),
'PORT TENDER': 'layers.vessel.type.portTender', 'PORT TENDER': msg('PORT TENDER'),
'ANTI-POLLUTION': 'layers.vessel.type.antiPollution', 'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
'LAW ENFORCE': 'layers.vessel.type.lawEnforce', 'LAW ENFORCE': msg('LAW ENFORCE'),
MEDICAL: 'layers.vessel.type.medical', MEDICAL: msg('MEDICAL'),
'HIGH-SPEED': 'layers.vessel.type.highSpeed', 'HIGH-SPEED': msg('HIGH-SPEED'),
PASSENGER: 'layers.vessel.type.passenger', PASSENGER: msg('PASSENGER'),
CARGO: 'layers.vessel.type.cargo', CARGO: msg('CARGO'),
TANKER: 'layers.vessel.type.tanker', TANKER: msg('TANKER'),
OTHER: 'layers.vessel.type.other', 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 // Choosing a mission is deliberately NOT durable suppression: picking a mission
// is enthusiasm, not "never show me this again". // is enthusiasm, not "never show me this again".
import { t } from './i18n/index.js'; import { t, msg } from './i18n/index.js';
/** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */
export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1';
@@ -39,9 +39,9 @@ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL';
// Catalog keys per label choice. Resolved through t() at CALL time (init), // 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. // never at module load — the locale is not resolved until main.js's body runs.
const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({
ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental', ENVIRONMENTAL: msg('ENVIRONMENTAL'),
EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch', EARTH_WATCH: msg('EARTH WATCH'),
ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents', ACTIVE_EVENTS: msg('ACTIVE EVENTS'),
}); });
/** /**
@@ -98,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
contacts: Object.freeze({ contacts: Object.freeze({
kind: 'context', kind: 'context',
contextMode: 'contacts', contextMode: 'contacts',
busyKey: 'setup.firstRun.busy.contacts', busyKey: msg('Starting live contacts…'),
}), }),
'space-missions': Object.freeze({ 'space-missions': Object.freeze({
kind: 'context', kind: 'context',
contextMode: 'space-missions', contextMode: 'space-missions',
busyKey: 'setup.firstRun.busy.spaceMissions', busyKey: msg('Opening space missions…'),
}), }),
environmental: Object.freeze({ environmental: Object.freeze({
kind: 'globe', kind: 'globe',
@@ -121,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors // before a launch. LEDGERED post-launch. Until it lands, keyless visitors
// are judged on the layer row, which tells them the truth. // are judged on the layer row, which tells them the truth.
layerIds: Object.freeze(['earthquakes', 'local-firms']), layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyKey: 'setup.firstRun.busy.environmental', busyKey: msg('Scanning active events…'),
}), }),
explore: Object.freeze({ kind: 'none' }), explore: Object.freeze({ kind: 'none' }),
}); });
@@ -375,9 +375,14 @@ export function initFirstRunExperience({
'[data-first-run-environmental-title] + small', '[data-first-run-environmental-title] + small',
); );
if (environmentalSub) 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'); 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 status = root.querySelector('[data-first-run-status]');
const suppressBox = root.querySelector('[data-first-run-suppress]'); const suppressBox = root.querySelector('[data-first-run-suppress]');
@@ -461,7 +466,7 @@ export function initFirstRunExperience({
if (!status) return; if (!status) return;
if (next) if (next)
status.textContent = t( 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') else if (status.dataset.sticky !== 'true')
status.textContent = defaultStatus; status.textContent = defaultStatus;
@@ -512,7 +517,10 @@ export function initFirstRunExperience({
Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : '';
if (status) { if (status) {
status.dataset.sticky = 'true'; status.dataset.sticky = 'true';
status.textContent = t('setup.firstRun.status.failed', { detail }); status.textContent = t(
'Could not open that mission{detail}. Retry or explore manually.',
{ detail },
);
} }
setBusy(false); setBusy(false);
}; };
@@ -528,7 +536,9 @@ export function initFirstRunExperience({
if (box) box.checked = !wanted; if (box) box.checked = !wanted;
if (!status) return; if (!status) return;
status.dataset.sticky = 'true'; status.dataset.sticky = 'true';
status.textContent = t('setup.firstRun.status.storageBlocked'); status.textContent = t(
'This browser is blocking storage, so that could not be saved.',
);
}; };
const keyboard = createSurfaceKeyboard({ 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-system">${this._missionId} ${this._sensorId}</div>
<div class="hud-mode" id="hud-mode">NORMAL</div> <div class="hud-mode" id="hud-mode">NORMAL</div>
<div class="hud-summary-wrap"> <div class="hud-summary-wrap">
<div class="hud-summary-label">${t('cockpit.hud.summaryLabel')}</div> <div class="hud-summary-label">${t('SUMMARY')}</div>
<div class="hud-summary" id="hud-summary">${t('cockpit.hud.summaryAwaiting')}</div> <div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="hud-corner hud-top-right"> <div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right"> <div class="hud-content" style="text-align:right">
<div class="hud-rec"><span id="hud-rec-dot">●</span> ${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 class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div>
</div> </div>
<div class="hud-bracket">┐</div> <div class="hud-bracket">┐</div>
@@ -635,7 +635,7 @@ export class IntelHUD {
*/ */
_composeSummary() { _composeSummary() {
const m = this._latestMetrics; const m = this._latestMetrics;
if (!m) return t('cockpit.hud.summaryAwaiting'); if (!m) return t('Awaiting telemetry...');
const modeEl = document.getElementById('hud-mode'); const modeEl = document.getElementById('hud-mode');
const modeLabel = modeEl?.textContent || 'NORMAL'; const modeLabel = modeEl?.textContent || '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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { getCatalog, mergeNamespace } from './index.js';
import { CATALOG_LOCALES } from './locale.js';
/* import {
* PARITY FLIP — how the strict gate is previewed or relaxed. checkCatalog,
* ───────────────────────────────────────────────────────────────────────────── extractMessages,
* REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the readCatalog,
* exact en key set. While a NEW catalog is still an untranslated seed that shippedLocales,
* lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to } from '../../scripts/i18n.mjs';
* the subset rules (a locale may be a subset of en, never a superset). The import { LOCALES } from './locale.js';
* 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';
const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; const messages = extractMessages();
/** Placeholder names used by one catalog entry (string or plural variants). */ test('every catalog file is a shipped locale and every non-English locale has a catalog', () => {
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));
assert.deepEqual( assert.deepEqual(
missing, shippedLocales().sort(),
[], LOCALES.filter((locale) => locale !== 'en').sort(),
`${locale}:${key} must keep every en variant (${enVariants.join(',')})`,
); );
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 of shippedLocales()) {
for (const [locale, keys] of localeKeys) { test(`${locale} catalog matches the code (npm run i18n)`, () => {
const extras = keys.filter((key) => !enKeys.includes(key)); const { missing, stale, errors } = checkCatalog(
assert.deepEqual( locale,
extras, readCatalog(locale),
[], messages,
`extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`,
); );
} assert.deepEqual(
}); { missing, stale, errors },
{ missing: [], stale: [], errors: [] },
);
});
}
test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => { test('checkCatalog reports missing, stale, placeholder and plural problems', () => {
const enCatalog = getCatalog('en'); const fixture = new Map([
for (const [locale, keys] of localeKeys) { ['Saved {name}', { plural: false }],
const catalog = getCatalog(locale); ['{count} keys', { plural: true, one: '{count} key' }],
for (const key of keys) { ['Untranslated', { plural: false }],
assert.deepEqual( ]);
[...placeholdersOf(catalog[key])].sort(), const result = checkCatalog(
[...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,
'ru', 'ru',
'layers.clear.toast.cleared', {
)); 'Saved {name}': 'Сохранено {nmae}',
// …while a variant Intl never selects for ru (a typo like 'xother') fails. '{count} keys': {
assert.throws( one: '{count} ключ',
() => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'), few: '{count} ключа',
/xother/, many: '{count} ключей',
},
Removed: 'Удалено',
},
fixture,
); );
// Dropping a mandatory en variant fails even when all additions are valid. assert.deepEqual(result.missing, ['Untranslated']);
assert.throws( assert.deepEqual(result.stale, ['Removed']);
() => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'), assert.equal(result.errors.length, 2);
/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']);
}); });
+175 -427
View File
@@ -1,457 +1,205 @@
// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded import { test, afterEach } from 'node:test';
// storage, English fallback, interpolation, plural selection, document
// application on all four attributes, and hash-exact reload handling.
import { test } from 'node:test';
import assert from 'node:assert/strict'; 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 { import {
applyDocumentTranslations, applyDocumentTranslations,
formatDate, formatDate,
formatNumber, formatNumber,
formatRelativeTime, msg,
getCatalog,
getLocale,
hasMessage,
persistLocaleAndReload, persistLocaleAndReload,
resolveMessage,
selectPluralPattern,
setLocale, setLocale,
t, t,
tn,
} from './index.js'; } 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. afterEach(() => setLocale('en'));
function pinLocale(locale) {
assert.equal(setLocale(locale), locale);
}
test('normalizeLocale folds regional variants and rejects unsupported tags', () => { function memoryStorage(initial = {}) {
assert.equal(normalizeLocale('es'), 'es'); const data = new Map(Object.entries(initial));
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) {
return { return {
querySelectorAll(selector) { getItem: (key) => (data.has(key) ? data.get(key) : null),
const match = /^\[([a-z0-9-]+)\]$/.exec(selector); setItem: (key, value) => data.set(key, String(value)),
if (!match) return []; data,
return elements.filter((element) => element.getAttribute(match[1]) !== null);
},
}; };
} }
test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => { test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
pinLocale('en'); assert.equal(normalizeLocale('ru-RU'), 'ru');
const elements = [ assert.equal(normalizeLocale(' RU '), 'ru');
new FakeElement({ 'data-i18n': 'shell.title.subtitle' }), assert.equal(normalizeLocale('en_GB'), 'en');
new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }), assert.equal(normalizeLocale('xx'), null);
new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }), assert.equal(normalizeLocale(undefined), null);
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('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => { test('resolveLocale: ?lang= wins, then the stored choice, then browser languages, then English', () => {
assert.deepEqual([...CATALOG_LOCALES], ['en'], const storage = memoryStorage({ [LOCALE_STORAGE_KEY]: 'ru' });
'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', () => {
assert.equal( assert.equal(
resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }), resolveLocale({
location: { search: '?lang=en' },
storage,
languages: ['ru'],
}),
'en', 'en',
'fr has no catalog: the override falls through to the stored preference', );
assert.equal(
resolveLocale({ location: { search: '' }, storage, languages: ['en'] }),
'ru',
); );
assert.equal( assert.equal(
resolveLocale({ resolveLocale({
location: { search: '?lang=es' }, location: { search: '?lang=xx' },
storage: { getItem: () => 'en' }, storage: memoryStorage(),
languages: null, languages: ['de-DE', 'ru-RU'],
config: { defaultLocale: 'es', secondaryLocale: 'fr' }, }),
'ru',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: memoryStorage(),
languages: ['de'],
}), }),
'en', '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', () => { test('a throwing storage never escapes', () => {
// A stored 'fr'/'es' reads as absent while those locales are unshipped; const storage = {
// resolution defers to the navigator list and finally the built-in default. getItem() {
assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en'); throw new Error('blocked');
assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en', },
'…down to the default'); 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)', () => { test('applyDocumentLanguage sets lang and dir', () => {
const warnings = []; const doc = { documentElement: {} };
const originalWarn = console.warn; applyDocumentLanguage(doc, 'ru-RU');
console.warn = (...args) => warnings.push(args); assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
try { });
const badPairs = [
{ defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code test('t() returns English text in English and translates in Russian', () => {
{ defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice assert.equal(t('POWERED UP'), 'POWERED UP');
{ defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only setLocale('ru');
{ defaultLocale: 'english', secondaryLocale: 'ES' }, assert.notEqual(t('POWERED UP'), 'POWERED UP');
{ defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog });
];
for (const bad of badPairs) { test('t() interpolates named values and keeps unknown names literal', () => {
const pair = resolveLocalePair(bad); assert.equal(
assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad)); t('Save failed: {detail}', { detail: 'disk full' }),
assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad)); 'Save failed: disk full',
assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad)); );
assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en'); assert.equal(
} t('Hello {who} {missing}', { who: 'pilot' }),
} finally { 'Hello pilot {missing}',
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. test('t() falls back to English for text without a translation', () => {
assert.deepEqual(warnings, []); 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 // God's Eye View i18n: the English source text is the message key.
// 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).
// //
// APPEND-ONLY NAMESPACE REGISTRATION // t('Save keys') → 'Сохранить ключи'
// ───────────────────────────────────────────────────────────────────────────── // t('Cleared {name}', { name }) → named interpolation
// A worker adding a namespace creates locales/<locale>/<ns>.js (exporting // tn(count, '{count} camera', '{count} cameras') → plural by Intl.PluralRules
// NAMESPACE plus a default message map of namespace-relative keys), then // msg('Live Flights') → marks a string kept in data
// appends ONE import line per locale and pushes the module into that locale's // for a later t(); returns it as is
// array below. Nothing else changes, so concurrent workers merge trivially. //
// Namespace-relative keys are prefixed at registration time: shell.*, // English needs no catalog. Every other locale is one flat JSON file,
// cockpit.*, layers.*, setup.*. // 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 { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
normalizeLocale, normalizeLocale,
writeStoredLocale, writeStoredLocale,
} from './locale.js'; } from './locale.js';
import ru from './locales/ru.json' with { type: 'json' };
import * as enShell from './locales/en/shell.js'; const CATALOGS = Object.freeze({ ru });
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;
}
let currentLocale = DEFAULT_LOCALE; 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() { export function getLocale() {
return currentLocale; return currentLocale;
} }
@@ -106,44 +45,74 @@ export function setLocale(locale) {
return currentLocale; return currentLocale;
} }
/** Development-only missing-key warning. Off under node:test and prod builds. */ /** Development-only warning; silent under node:test and in production. */
function warnMissingKey(key, detail) { function warnMissing(text) {
// 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; 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, * Translate English source text into the active locale.
* then the English fallback catalog. * @param {string} text English text, e.g. 'Hold Space to talk'
* @param {Record<string, object>} catalogs Catalog map to read from. * @param {object} [values] Named interpolation values
* @param {string} locale Locale to resolve within that map. * @returns {string}
* @param {string} key Dot-prefixed message key.
* @returns {string|object|undefined} Raw entry, or undefined when nowhere.
*/ */
export function resolveMessage(catalogs, locale, key) { export function t(text, values) {
const source = catalogs || CATALOGS; const entry = lookup(text);
const normalized = normalizeLocale(locale); return interpolate(typeof entry === 'string' ? entry : text, values);
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;
} }
/** Whether the key resolves in the active locale or the English fallback. */ /**
export function hasMessage(key) { * Translate a count-dependent message. The catalog entry for `other` holds
return resolveMessage(CATALOGS, currentLocale, key) !== undefined; * 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(); const pluralRulesCache = new Map();
function pluralRulesFor(locale) { function pluralRules(locale) {
let rules = pluralRulesCache.get(locale); let rules = pluralRulesCache.get(locale);
if (!rules) { if (!rules) {
rules = new Intl.PluralRules(locale); rules = new Intl.PluralRules(locale);
@@ -152,60 +121,10 @@ function pluralRulesFor(locale) {
return rules; 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 * Intl formatters are memoized per (kind, locale, options): the constructors
* are expensive and stage-3 extraction will format numbers and dates on panel * are expensive and panels format on refresh paths. The cache is cleared
* refresh paths. The cache is cleared whenever the active locale changes. * whenever the active locale changes.
*/ */
const formatterCache = new Map(); const formatterCache = new Map();
@@ -230,12 +149,8 @@ export function formatDate(value, options) {
} }
/** /**
* Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed * Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* distance in `unit`, e.g. formatRelativeTime(-5, 'minute') → * formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
* '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).
*/ */
export function formatRelativeTime(value, unit = 'second', options) { export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, { return cachedFormatter('relative', Intl.RelativeTimeFormat, {
@@ -244,54 +159,46 @@ export function formatRelativeTime(value, unit = 'second', options) {
}).format(value, unit); }).format(value, unit);
} }
/** DOM write targets for applyDocumentTranslations: textContent plus the /** Collapse markup whitespace so a message reads the same in HTML and JS. */
* three presentation attributes vision.md requires to agree with visible export function normalizeMarkupText(text) {
* labels. Never innerHTML — translation functions return 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([ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
Object.freeze({ ['data-i18n', null],
attribute: 'data-i18n', ['data-i18n-title', 'title'],
apply(element, text) { ['data-i18n-aria-label', 'aria-label'],
element.textContent = text; ['data-i18n-placeholder', 'placeholder'],
},
}),
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);
},
}),
]); ]);
/** /**
* Apply catalog text to every element carrying an i18n attribute in `doc`. * Translate every opted-in element in `doc`. Runs once at boot; switching
* Elements whose key resolves nowhere are left untouched (warned in dev). * locale reloads the page.
* @param {Document} doc * @param {Document|Element} doc
* @returns {number} Count of element/attribute writes performed. * @returns {number} Count of element/attribute writes performed.
*/ */
export function applyDocumentTranslations(doc) { export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0; if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
let applied = 0; let applied = 0;
for (const target of DOCUMENT_TRANSLATION_TARGETS) { for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${target.attribute}]`)) { for (const element of doc.querySelectorAll(`[${marker}]`)) {
const key = element.getAttribute?.(target.attribute); const source = attribute
if (!key) continue; ? element.getAttribute(attribute)
if (!hasMessage(key)) { : normalizeMarkupText(element.textContent);
warnMissingKey(key, `attribute ${target.attribute}`); if (!source) continue;
continue; const text = t(source);
} if (text === source) continue;
target.apply(element, t(key)); if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
applied += 1; applied += 1;
} }
} }
@@ -300,13 +207,8 @@ export function applyDocumentTranslations(doc) {
/** /**
* Persist a locale choice, then reload preserving window.location.hash exactly * Persist a locale choice, then reload preserving window.location.hash exactly
* and stripping ?lang from the search string — the override is one-shot and * and stripping ?lang from the search string, so the override is one-shot.
* must never graduate into the stored URL. Storage is best-effort: where * Storage is best-effort: where it is blocked the choice will not survive.
* 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.
* @returns {boolean} true when a reload was triggered. * @returns {boolean} true when a reload was triggered.
*/ */
export function persistLocaleAndReload( export function persistLocaleAndReload(
@@ -320,20 +222,16 @@ export function persistLocaleAndReload(
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
writeStoredLocale(normalized, storage); writeStoredLocale(normalized, storage);
if (!location || typeof location.assign !== 'function') return false; if (!location || typeof location.assign !== 'function') return false;
const stripLang = () => { let target = location.href;
try { try {
const url = new URL(location.href); const url = new URL(location.href);
url.searchParams.delete('lang'); url.searchParams.delete('lang');
return url.href; target = url.href;
} catch { } catch {
return location.href; // Keep the current href.
} }
}; // location.assign() to the identical URL does not navigate, so strip ?lang
// location.assign() to the byte-identical URL performs NO navigation in // in place and force a real reload instead.
// 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();
try { try {
history?.replaceState?.(null, '', target); history?.replaceState?.(null, '', target);
} catch { } catch {
+29 -256
View File
@@ -1,249 +1,52 @@
// Locale identity for God's Eye View (i18n phase 1). // Which locale the page runs in: ?lang= → stored choice → browser languages →
// // English. Only shipped locales are ever selected.
// 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.
/** Durable preference key. Matches the repo convention gev:<name>:v1. */ /** Durable preference key. Matches the repo convention gev:<name>:v1. */
export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
/** /** Source language and fallback. */
* 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. */
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** /**
* Built-in pair used when no env/config shapes one, or a configured one is * Shipped locales, in selector order. Adding one: locales/<code>.json, an
* unusable. English-only while English is the only shipped catalog: the * import in index.js, and an entry here (see docs/TRANSLATORS.md).
* selector degenerates to a single EN button until a secondary locale PR
* restores the built-in pair.
*/ */
const FALLBACK_PAIR = Object.freeze({ export const LOCALES = Object.freeze(['en', 'ru']);
defaultLocale: 'en',
secondaryLocale: 'en',
});
/** Document metadata per normalizable locale code. All of them are LTR. */ /** Each locale named in its own language, for the selector. */
export const LOCALE_METADATA = Object.freeze({ export const LOCALE_NAMES = Object.freeze({ en: 'English', ru: 'Русский' });
en: Object.freeze({ dir: 'ltr' }),
es: Object.freeze({ dir: 'ltr' }), /** Right-to-left locales. */
fr: Object.freeze({ dir: 'ltr' }), const RTL_LOCALES = Object.freeze([]);
ru: Object.freeze({ dir: 'ltr' }),
uk: Object.freeze({ dir: 'ltr' }),
});
/** /**
* Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk' * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the * @returns {string|null}
* 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}
*/ */
export function normalizeLocale(candidate) { export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null; if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
return NORMALIZABLE_LOCALES.includes(primary) ? primary : null; return LOCALES.includes(primary) ? primary : null;
} }
/** function queryLocale(location) {
* 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() {
try { try {
return { return normalizeLocale(new URLSearchParams(location?.search).get('lang'));
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,
);
} catch { } catch {
return null; return null;
} }
} }
/** Normalize a candidate, keeping it only when the pair offers that locale. */ function readStoredLocale(storage) {
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) {
try { try {
const store = storage !== undefined ? storage : globalThis.localStorage; const store = storage !== undefined ? storage : globalThis.localStorage;
return normalizeIfAvailable( return normalizeLocale(store?.getItem?.(LOCALE_STORAGE_KEY));
store?.getItem?.(LOCALE_STORAGE_KEY),
availableLocales,
);
} catch { } catch {
return null; return null;
} }
} }
/** /** Store the choice; false when storage is unavailable. */
* 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.
*/
export function writeStoredLocale(locale, storage) { export function writeStoredLocale(locale, storage) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
try { try {
@@ -252,63 +55,33 @@ export function writeStoredLocale(locale, storage) {
store.setItem(LOCALE_STORAGE_KEY, normalized); store.setItem(LOCALE_STORAGE_KEY, normalized);
return true; return true;
} catch { } catch {
// Privacy-restricted or quota-exhausted storage must never break the app.
return false; return false;
} }
} }
/** /** Resolve the boot locale. Every input is injectable for tests. */
* 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}
*/
export function resolveLocale({ export function resolveLocale({
location = globalThis.location, location = globalThis.location,
storage, storage,
languages = globalThis.navigator?.languages, languages = globalThis.navigator?.languages,
config,
} = {}) { } = {}) {
const { availableLocales: offered, defaultLocale } = const fromQuery = queryLocale(location);
resolveLocalePair(config); if (fromQuery) return fromQuery;
const override = queryLocaleOverride(location, offered); const stored = readStoredLocale(storage);
if (override) return override;
const stored = readStoredLocale(storage, offered);
if (stored) return stored; if (stored) return stored;
if (Array.isArray(languages)) { for (const candidate of Array.isArray(languages) ? languages : []) {
for (const candidate of languages) { const normalized = normalizeLocale(candidate);
const normalized = normalizeIfAvailable(candidate, offered); if (normalized) return normalized;
if (normalized) return normalized;
}
} }
return defaultLocale; return DEFAULT_LOCALE;
} }
/** /** Set <html lang> and <html dir>. */
* 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.
*/
export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) { export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) {
const root = doc?.documentElement; const root = doc?.documentElement;
if (!root) return false; if (!root) return false;
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
root.lang = normalized; root.lang = normalized;
root.dir = ( root.dir = RTL_LOCALES.includes(normalized) ? 'rtl' : 'ltr';
LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE]
).dir;
return true; 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. * 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 * 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) { export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0)); const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0 return missing > 0
? t('setup.keySetup.chipWaiting', { count: missing }) ? tn(
: t('setup.keySetup.chipReady'); 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'); const unlocks = documentRef.createElement('p');
unlocks.className = 'key-setup-unlocks'; unlocks.className = 'key-setup-unlocks';
// The registry `unlocks` text localizes per registry id; an id missing from unlocks.textContent = t(key.unlocks);
// 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;
row.append(head, unlocks); row.append(head, unlocks);
if (!external) { if (!external) {
@@ -214,8 +215,10 @@ function buildRow(documentRef, key) {
remove.type = 'button'; remove.type = 'button';
remove.className = 'key-setup-remove'; remove.className = 'key-setup-remove';
remove.dataset.keySetupRemove = JSON.stringify(key.envVars); remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
remove.textContent = t('setup.keySetup.row.remove'); remove.textContent = t('REMOVE');
remove.title = t('setup.keySetup.row.removeTitle', { title: key.title }); remove.title = t("Remove {title} from this app's saved keys", {
title: key.title,
});
fields.append(remove); fields.append(remove);
} }
row.append(fields); row.append(fields);
@@ -380,8 +383,8 @@ export async function initKeySetup({
const storeLabel = () => const storeLabel = () =>
status?.store === 'pinokio-environment' status?.store === 'pinokio-environment'
? t('setup.keySetup.store.pinokio') ? t('your app configuration')
: t('setup.keySetup.store.env'); : t('your local .env');
// doneMessageKey names the whole saved/removed sentence; the storage // doneMessageKey names the whole saved/removed sentence; the storage
// destination is interpolated so the {store} phrase stays one spelling. // destination is interpolated so the {store} phrase stays one spelling.
@@ -392,7 +395,7 @@ export async function initKeySetup({
)?.set; )?.set;
busy = true; busy = true;
applyButton?.setAttribute('aria-disabled', 'true'); applyButton?.setAttribute('aria-disabled', 'true');
say(t('setup.keySetup.status.saving')); say(t('Saving…'));
try { try {
const response = await doFetch('/api/setup/keys', { const response = await doFetch('/api/setup/keys', {
method: 'POST', method: 'POST',
@@ -406,7 +409,7 @@ export async function initKeySetup({
// payload.error is a server-authored message (API value) — shown as-is. // payload.error is a server-authored message (API value) — shown as-is.
say( say(
payload.error || payload.error ||
t('setup.keySetup.status.saveFailed', { status: response.status }), t('Save failed ({status}).', { status: response.status }),
); );
return; return;
} }
@@ -440,7 +443,7 @@ export async function initKeySetup({
say(t(doneMessageKey, { store: storeLabel() })); say(t(doneMessageKey, { store: storeLabel() }));
} catch (error) { } catch (error) {
say( say(
t('setup.keySetup.status.saveFailedDetail', { t('Save failed: {detail}', {
detail: error?.message || error, detail: error?.message || error,
}), }),
); );
@@ -460,10 +463,13 @@ export async function initKeySetup({
})), })),
); );
if (!Object.keys(updates).length) { if (!Object.keys(updates).length) {
say(t('setup.keySetup.status.pasteFirst')); say(t('Paste at least one key first.'));
return; return;
} }
await submitUpdates(updates, 'setup.keySetup.status.saved'); await submitUpdates(
updates,
msg('Saved to {store}. Restarting — this page reloads itself.'),
);
}; };
chip.addEventListener('click', openDialog); chip.addEventListener('click', openDialog);
@@ -579,11 +585,11 @@ export async function initKeySetup({
// a deliberate two-step the lure cannot pre-satisfy. // a deliberate two-step the lure cannot pre-satisfy.
const ok = const ok =
typeof globalThis.confirm !== 'function' || typeof globalThis.confirm !== 'function' ||
globalThis.confirm(t('setup.keySetup.confirm.remove')); globalThis.confirm(t('Remove this key from your saved configuration?'));
if (!ok) return; if (!ok) return;
void submitUpdates( void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])), Object.fromEntries(envVars.map((name) => [name, null])),
'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). * bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count. * `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([ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'google-maps', id: 'google-maps',
title: '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', getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']), envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered', tier: 'metered',
@@ -45,7 +49,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'google-maps-server', id: 'google-maps-server',
title: 'GOOGLE MAPS — SERVER', title: 'GOOGLE MAPS — SERVER',
unlocks: 'Places context + Street View fallback; optional separate key', unlocks: msg(
'Places context + Street View fallback; optional separate key',
),
getUrl: getUrl:
'https://developers.google.com/maps/documentation/places/web-service/get-api-key', 'https://developers.google.com/maps/documentation/places/web-service/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']), envVars: Object.freeze(['GOOGLE_MAPS_SERVER_API_KEY']),
@@ -55,7 +61,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'openai', id: 'openai',
title: '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', getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']), envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered', tier: 'metered',
@@ -63,7 +69,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'aisstream', id: 'aisstream',
title: 'AISSTREAM', title: 'AISSTREAM',
unlocks: 'Live ships, worldwide', unlocks: msg('Live ships, worldwide'),
getUrl: 'https://aisstream.io', getUrl: 'https://aisstream.io',
envVars: Object.freeze(['AISSTREAM_API_KEY']), envVars: Object.freeze(['AISSTREAM_API_KEY']),
tier: 'free', tier: 'free',
@@ -71,7 +77,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'firms', id: 'firms',
title: 'NASA 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/', getUrl: 'https://firms.modaps.eosdis.nasa.gov/api/map_key/',
envVars: Object.freeze(['FIRMS_MAP_KEY']), envVars: Object.freeze(['FIRMS_MAP_KEY']),
tier: 'free', tier: 'free',
@@ -79,7 +85,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'tomtom', id: 'tomtom',
title: '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', getUrl: 'https://my.tomtom.com/keys',
envVars: Object.freeze(['TOMTOM_API_KEY']), envVars: Object.freeze(['TOMTOM_API_KEY']),
tier: 'free', tier: 'free',
@@ -87,7 +93,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'cesium-ion', id: 'cesium-ion',
title: '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', getUrl: 'https://ion.cesium.com/tokens',
envVars: Object.freeze(['CESIUM_ION_TOKEN']), envVars: Object.freeze(['CESIUM_ION_TOKEN']),
tier: 'free', tier: 'free',
@@ -96,7 +102,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'opensky', id: 'opensky',
title: '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', getUrl: 'https://opensky-network.org',
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']), envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free', tier: 'free',
@@ -104,8 +110,9 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'mapillary', id: 'mapillary',
title: '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', 'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
),
getUrl: 'https://www.mapillary.com/dashboard/developers', getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']), envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free', tier: 'free',
@@ -114,7 +121,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'launch-library', id: 'launch-library',
title: 'LAUNCH LIBRARY', title: 'LAUNCH LIBRARY',
unlocks: 'Higher space-missions request allowance', unlocks: msg('Higher space-missions request allowance'),
getUrl: 'https://thespacedevs.com', getUrl: 'https://thespacedevs.com',
envVars: Object.freeze(['LL2_API_TOKEN']), envVars: Object.freeze(['LL2_API_TOKEN']),
tier: 'free', tier: 'free',
+10 -11
View File
@@ -57,8 +57,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() { function hidePanel() {
if (layerState.panel) { if (layerState.panel) {
const markup = `<div class="military-awareness-standby"> const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? t('layers.awareness.standbyReady') : t('layers.awareness.standbyOff')}</strong> <strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('layers.awareness.standbySelect') : t('layers.awareness.standbyEnable')}</span> <span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`; </div>`;
layerState.panel.hidden = false; layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) { if (layerState.panelMarkup !== markup) {
@@ -79,11 +79,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item); const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id; const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) { if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="${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 = const accessibleLabel = label === '—' ? t('Unavailable') : label;
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('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('layers.awareness.focusAria', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
}) })
.join(''); .join('');
const pageCount = Math.max( const pageCount = Math.max(
@@ -104,10 +103,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
function navigationControlsHtml() { function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0; const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="${t('layers.awareness.controlsAria')}"> return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<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="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('layers.awareness.focus')}</button> <button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('layers.awareness.nextTitle')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('layers.awareness.next')}</button> <button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`; </div>`;
} }
@@ -189,7 +188,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
${namedAreasHtml(subject.memberNames)} ${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()} ${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')} ${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${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; panel.hidden = false;
if (layerState.panelMarkup !== markup) { if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel); const focusSnapshot = captureAwarenessPanelFocus(panel);
+4 -4
View File
@@ -138,7 +138,7 @@ export function createRendering({
'--compass-rotation', '--compass-rotation',
`${-headingDeg}deg`, `${-headingDeg}deg`,
); );
layerState.compassHeading.textContent = t('layers.awareness.hdg', { layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'), value: String(headingDeg).padStart(3, '0'),
}); });
layerState.compassHeading.style.left = `${geometry.centerX}px`; layerState.compassHeading.style.left = `${geometry.centerX}px`;
@@ -224,12 +224,12 @@ export function createRendering({
) )
: null; : null;
const bearingText = Number.isFinite(bearing) const bearingText = Number.isFinite(bearing)
? t('layers.awareness.brg', { ? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'), value: String(Math.round(bearing)).padStart(3, '0'),
}) })
: t('layers.awareness.brgUnknown'); : t('BRG —');
const courseText = Number.isFinite(item.track) const courseText = Number.isFinite(item.track)
? ` · ${t('layers.awareness.crs', { ? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'), 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) { function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label; if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY') if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
return t('layers.awareness.coverage.viewport');
const within = WITHIN_KM_RE.exec(label); 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; return label;
} }
@@ -94,7 +93,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
cohorts: [ cohorts: [
{ {
id: 'flights', id: 'flights',
label: t('layers.awareness.cohort.flights'), label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights, source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance( provenance: retainedProvenance(
'flights', 'flights',
@@ -113,7 +112,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}, },
{ {
id: 'military', id: 'military',
label: t('layers.awareness.cohort.military'), label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military, source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance( provenance: retainedProvenance(
'military', 'military',
@@ -132,7 +131,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}, },
{ {
id: 'ais-live-vessels', id: 'ais-live-vessels',
label: t('layers.awareness.cohort.vessels'), label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance( provenance: retainedProvenance(
'ais-live-vessels', 'ais-live-vessels',
@@ -147,7 +146,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}, },
{ {
id: 'military-installations', id: 'military-installations',
label: t('layers.awareness.cohort.installations'), label: t('Mapped installations'),
source: source:
installationsState.stats.source || installationsState.stats.source ||
SOURCE_LABEL['military-installations'], SOURCE_LABEL['military-installations'],
@@ -159,7 +158,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
), ),
coverage: coverage:
localizeCoverageLabel(installationsState.stats.coverageLabel) || localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('layers.awareness.coverage.viewport'), t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport( summary: parts.queries.summarizeInstallationViewport(
installations, installations,
installationsState, installationsState,
+2 -2
View File
@@ -68,10 +68,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) { if (layerState._loading) {
stats.loadingLabel = stats.loadingLabel =
layerState._activeCityIds.size > 0 layerState._activeCityIds.size > 0
? t('layers.bike.loadingSyncing', { ? t('syncing {count} city feeds...', {
count: layerState._activeCityIds.size, count: layerState._activeCityIds.size,
}) })
: t('layers.bike.loadingScanning'); : t('scanning nearby systems...');
} }
if (layerState._error) stats.error = layerState._error; if (layerState._error) stats.error = layerState._error;
return stats; return stats;
+10 -9
View File
@@ -24,8 +24,8 @@ export function createSelection({
const stationLabel = const stationLabel =
stationName || stationName ||
(record?.stationId (record?.stationId
? t('layers.bike.stationWithId', { id: record.stationId }) ? t('Station {id}', { id: record.stationId })
: t('layers.bike.stationFallback')); : t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable) const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable ? record.bikesAvailable
: '?'; : '?';
@@ -36,17 +36,18 @@ export function createSelection({
const lines = [ const lines = [
stationLabel, 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 // Append warnings for stations that are offline or partially non-operational
const abnormal = []; const abnormal = [];
if (record?.isInstalled === false) if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
abnormal.push(t('layers.bike.warning.notInstalled')); if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
if (record?.isRenting === false) if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
abnormal.push(t('layers.bike.warning.notRenting'));
if (record?.isReturning === false)
abnormal.push(t('layers.bike.warning.notReturning'));
if (abnormal.length > 0) { if (abnormal.length > 0) {
lines.push(abnormal.join(' · ')); 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 // The token copy lives in the layers catalog (en is byte-identical to the
// literals these replace); presentation stays here, translation at read. // literals these replace); presentation stays here, translation at read.
return isHeadingEstimated(camera) return isHeadingEstimated(camera)
? t('layers.cctv.summary.hdgEstimated', { value: hdg }) ? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('layers.cctv.summary.hdg', { value: hdg }); : t('HDG {value}°', { value: hdg });
} }
+18 -19
View File
@@ -20,10 +20,13 @@ export function createPresentation({
const active = parts.selection.getActiveRecord(); const active = parts.selection.getActiveRecord();
if (!active) { if (!active) {
return layerState._records.length return layerState._records.length
? t('layers.cctv.summary.standingBy', { ? t(
count: layerState._records.length, '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE',
}) {
: t('layers.cctv.summary.empty'); count: layerState._records.length,
},
)
: t('No cameras available in catalog.');
} }
const area = parts.model.sectorAreaKm2( const area = parts.model.sectorAreaKm2(
@@ -37,37 +40,33 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera); const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [ return [
t('layers.cctv.summary.city', { t('{city} CCTV', {
city: active.camera.city.toUpperCase(), city: active.camera.city.toUpperCase(),
}), }),
`${active.camera.name.toUpperCase()}`, `${active.camera.name.toUpperCase()}`,
// A synthetic bearing (headingConfidence 'low', no human calibration) // A synthetic bearing (headingConfidence 'low', no human calibration)
// is tagged so a hashed guess never reads as a surveyed facing (#639). // is tagged so a hashed guess never reads as a surveyed facing (#639).
headingHudToken(active.camera), headingHudToken(active.camera),
t('layers.cctv.summary.fov', { t('FOV {value}°', {
value: Math.round(active.camera.fovDeg), 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 overlapCount > 0
? t('layers.cctv.summary.overlap', { count: overlapCount }) ? t('OVERLAP {count} cams', { count: overlapCount })
: t('layers.cctv.summary.isolated'), : t('ISOLATED VIEW'),
layerState._showProjection layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
? t('layers.cctv.summary.projMonitor') layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
: t('layers.cctv.summary.projOff'), t('CAL {value}', {
layerState._coverageMode === 'viewshed'
? t('layers.cctv.summary.viewshed')
: null,
t('layers.cctv.summary.cal', {
value: calBadge.replace('-', ' ').toUpperCase(), value: calBadge.replace('-', ' ').toUpperCase(),
}), }),
health?.sourceKind health?.sourceKind
? t('layers.cctv.summary.src', { ? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(), value: String(health.sourceKind).toUpperCase(),
}) })
: t('layers.cctv.summary.src', { : t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(), value: String(active.camera.feedType || 'image').toUpperCase(),
}), }),
t('layers.cctv.summary.context', { value: viewBand.toUpperCase() }), t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
] ]
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
+1 -1
View File
@@ -116,7 +116,7 @@ export function createCards({
if (!card.interactive) return card; if (!card.interactive) return card;
return { return {
...card, ...card,
accessibilityLabel: t('layers.firms.card.focusAria', { accessibilityLabel: t('Focus fire detection {title}, {details}', {
title: card.title, title: card.title,
details: card.details.join(', '), details: card.details.join(', '),
}), }),
+13 -21
View File
@@ -331,19 +331,17 @@ export function createModel({
function buildSelectedFireCard(fire, nowMs) { function buildSelectedFireCard(fire, nowMs) {
const meta = [ const meta = [
t('layers.firms.card.confSuffix', { t('{value} conf', {
value: confidenceBucket(fire.confidence), value: confidenceBucket(fire.confidence),
}), }),
]; ];
if (fire.acqMs > 0) { if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs); 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); const sat = satelliteShortName(fire.satellite);
meta.push( meta.push(
sat sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || t('sensor n/a'),
? `${fire.sensor || 'VIIRS'} ${sat}`
: fire.sensor || t('layers.firms.card.sensorUnavailable'),
); );
return { return {
id: `selected-fire:${fireDetectionKey(fire)}`, id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -353,11 +351,11 @@ export function createModel({
cullPosition: fireCullPosition(fire), cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp), gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name), 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: [ details: [
meta.join(' · '), meta.join(' · '),
formatLatLon(fire.lat, fire.lon) + formatLatLon(fire.lat, fire.lon) +
(fire.night ? ` · ${t('layers.firms.card.night')}` : ''), (fire.night ? ` · ${t('NIGHT')}` : ''),
], ],
selected: true, selected: true,
priority: Number.MAX_SAFE_INTEGER, priority: Number.MAX_SAFE_INTEGER,
@@ -390,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position, cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp), gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name), 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(' · ')], details: [meta.join(' · ')],
selected: false, selected: false,
priority: Number(fire.frp) || 0, priority: Number(fire.frp) || 0,
@@ -408,16 +406,11 @@ export function createModel({
function buildCellCard(candidate, nowMs) { function buildCellCard(candidate, nowMs) {
const cell = candidate.cell; const cell = candidate.cell;
const noun = const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
cell.count === 1 const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
? t('layers.firms.card.fireNoun')
: t('layers.firms.card.firesNoun');
const parts = [
t('layers.firms.card.maxFrp', { value: formatFrp(cell.maxFrp) }),
];
if (cell.newestAcqMs > 0) { if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs); 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 { return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`, 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). */ /** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) { function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) if (!Number.isFinite(deltaMs) || deltaMs < 0) return t('just now');
return t('layers.meta.justNow');
const minutes = Math.floor(deltaMs / 60000); const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return t('layers.firms.underMinuteAgo'); if (minutes < 1) return t('<1m ago');
if (minutes < 90) return t('layers.meta.minutesAgo', { count: minutes }); if (minutes < 90) return t('{count}m ago', { count: minutes });
return t('layers.meta.hoursAgo', { count: Math.round(minutes / 60) }); return t('{count}h ago', { count: Math.round(minutes / 60) });
} }
/** Normalized 0..1 confidence → low/nominal/high display bucket. */ /** Normalized 0..1 confidence → low/nominal/high display bucket. */
+7 -7
View File
@@ -60,24 +60,24 @@ export function createQueries({
getStats() { getStats() {
const now = Date.now(); const now = Date.now();
const staleText = layerState._lastUpdate const staleText = layerState._lastUpdate
? t('layers.firms.staleCached', { ? t('STALE · cached {age}', {
age: age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h', components.model.formatAge(now - layerState._lastUpdate) || '<1h',
}) })
: t('layers.status.stale'); : t('STALE');
let loadingLabel = ''; let loadingLabel = '';
if (layerState._loading) { if (layerState._loading) {
loadingLabel = layerState._fires.length loadingLabel = layerState._fires.length
? t('layers.meta.refreshing') ? t('refreshing...')
: t('layers.meta.loading'); : t('loading...');
} else if (layerState._keyRequired) { } else if (layerState._keyRequired) {
loadingLabel = t('layers.firms.keyRequired'); loadingLabel = t('KEY REQUIRED');
} else if (layerState._stale) { } else if (layerState._stale) {
loadingLabel = staleText; loadingLabel = staleText;
} else if (layerState._error) { } else if (layerState._error) {
loadingLabel = layerState._error; loadingLabel = layerState._error;
} else if (layerState._lastUpdate) { } else if (layerState._lastUpdate) {
loadingLabel = t('layers.firms.liveUpdated', { loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate), age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
}); });
} }
@@ -93,7 +93,7 @@ export function createQueries({
// the operator can take. // the operator can take.
keyRequired: layerState._keyRequired, keyRequired: layerState._keyRequired,
error: layerState._keyRequired error: layerState._keyRequired
? t('layers.firms.keyRequired') ? t('KEY REQUIRED')
: layerState._stale : layerState._stale
? staleText ? staleText
: layerState._error, : 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 spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
const stale = const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('layers.status.stale') ? t('STALE')
: ''; : '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')]; const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the // 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, DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M, ANCHOR_REFRESH_M,
} from './policy.js'; } from './policy.js';
import { t } from '../../i18n/index.js'; import { t, msg } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable // installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so // 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 // boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md). // live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({ const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': 'layers.installations.feedback.retrying', 'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': 'layers.installations.feedback.fetching', 'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': 'Zoom in to search mapped installations': msg(
'layers.installations.feedback.zoomIn', 'Zoom in to search mapped installations',
'Showing cached mapped sites': 'layers.installations.feedback.cached', ),
'Mapped sites not loaded': 'layers.installations.feedback.notLoaded', 'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites loaded': 'layers.installations.feedback.loaded', 'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Overpass rate-limited': 'layers.installations.feedback.reason.rateLimited', 'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass timed out': 'layers.installations.feedback.reason.timeout', 'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass could not complete the query': 'Overpass timed out': msg('Overpass timed out'),
'layers.installations.feedback.reason.queryFailed', 'Overpass could not complete the query': msg(
'Overpass temporarily unavailable': 'Overpass could not complete the query',
'layers.installations.feedback.reason.unavailable', ),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
}); });
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/; const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
@@ -38,7 +39,7 @@ function localizeInstallationFeedback(message) {
const retryIn = message.match(INSTALLATION_RETRY_IN_RE); const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) { if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]]; 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], reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2], seconds: retryIn[2],
}); });
@@ -46,7 +47,7 @@ function localizeInstallationFeedback(message) {
const pending = message.match(INSTALLATION_RETRY_PENDING_RE); const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) { if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]]; const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('layers.installations.feedback.retryPending', { return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1], reason: reasonKey ? t(reasonKey) : pending[1],
}); });
} }
@@ -280,7 +281,7 @@ export function createControls({ state: layerState, services, parts, source }) {
}), }),
), ),
loadingLabel: layerState.loading loadingLabel: layerState.loading
? t('layers.installations.loading') ? t('loading mapped installation context')
: '', : '',
coverage: layerState.coverage, coverage: layerState.coverage,
coverageLabel: coverageLabel:
+1 -1
View File
@@ -271,7 +271,7 @@ export function createRendering({
entity.gevTrackedId = `installations:${record.id}`; entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition; entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = { entity.gevLabelModel = {
title: record.name || t('layers.installations.fallbackTitle'), title: record.name || t('MAPPED INSTALLATION'),
details: [ details: [
String(record.class || 'installation') String(record.class || 'installation')
.replaceAll('_', ' ') .replaceAll('_', ' ')
+13 -14
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js'; import { t, msg } from '../../i18n/index.js';
import { import {
createCyberSonarSampler, createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive, isCyberContactSonarActive as isCyberSonarActive,
@@ -120,10 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const details = selected const details = selected
? [ ? [
siteName siteName
? t('layers.missions.launchSiteValue', { ? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(), site: shortMissionLabel(siteName, 20).toUpperCase(),
}) })
: t('layers.missions.launchSite'), : t('LAUNCH SITE'),
] ]
: []; : [];
return { return {
@@ -589,38 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const mission = shortMissionLabel(launch.name, 22).toUpperCase(); const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite); const siteName = compactLaunchSiteName(launch.launchSite);
const siteCallout = siteName const siteCallout = siteName
? t('layers.missions.launchSiteValue', { ? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(), site: shortMissionLabel(siteName, 20).toUpperCase(),
}) })
: t('layers.missions.launchSite'); : t('LAUNCH SITE');
let title = mission; let title = mission;
let detail = siteCallout; let detail = siteCallout;
if (mode === 'countdown') { if (mode === 'countdown') {
title = t('layers.missions.replay.countdown', { title = t('T−{time} · {mission}', {
time: String(state.countdownSeconds).padStart(2, '0'), time: String(state.countdownSeconds).padStart(2, '0'),
mission, mission,
}); });
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`; detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
} else if (mode === 'ascent') { } else if (mode === 'ascent') {
title = title =
state.elapsedSinceStart < 1 state.elapsedSinceStart < 1
? t('layers.missions.replay.liftoff', { mission }) ? t('LIFTOFF · {mission}', { mission })
: t( : t(
launch.trajectory.length > 1 launch.trajectory.length > 1
? 'layers.missions.replay.ascentReplay' ? msg('ASCENT REPLAY · {mission}')
: 'layers.missions.replay.ascentEstimate', : msg('ASCENT ESTIMATE · {mission}'),
{ mission }, { mission },
); );
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') { } 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); detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') { } else if (mode === 'orbit') {
title = t('layers.missions.replay.orbit', { mission }); title = t('ORBIT REPLAY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} }
if (layerState._replayPaused) if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
title = t('layers.missions.replay.pausedPrefix', { title });
const nextText = `${title}\n${detail}`; const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) { if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText; layerState._replayVehicleOverlayText = nextText;
+20 -23
View File
@@ -220,12 +220,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
] ]
.filter(Boolean) .filter(Boolean)
.join(' · '); .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) { if (launch.payloads.length > 5) {
payloadRows.push( 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( layerState._missionPanel.querySelector(
@@ -233,7 +233,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
).innerHTML = missionTableRows( ).innerHTML = missionTableRows(
payloadRows, payloadRows,
3, 3,
t('layers.missions.rows.classified'), t('PAYLOAD DATA UNAVAILABLE'),
); );
const stageRows = launch.recoveryStages.map((stage) => { const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint; const endpoint = stage.endpoint;
@@ -241,21 +241,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
stage.destination || stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS' (endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite ? launch.launchSite
: t('layers.missions.rows.unavailable')); : t('UNAVAILABLE'));
const position = endpoint const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0 : stage.downrangeKm > 0
? t('layers.missions.rows.kmDownrange', { ? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(), value: stage.downrangeKm.toLocaleString(),
}) })
: t('layers.missions.rows.positionUnavailable'); : t('POSITION UNAVAILABLE');
const stageDetail = [ const stageDetail = [
Number.isFinite(stage.flightNumber) Number.isFinite(stage.flightNumber)
? t('layers.missions.rows.flightNumber', { ? t('FLIGHT {count}', {
count: stage.flightNumber, count: stage.flightNumber,
}) })
: null, : null,
stage.reused ? t('layers.missions.rows.reused') : null, stage.reused ? t('REUSED') : null,
stage.recoveryType, stage.recoveryType,
] ]
.filter(Boolean) .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>`; 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 = 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( const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]', '[data-mission-stages-section]',
); );
@@ -291,7 +291,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
'[data-mission-roster-count]', '[data-mission-roster-count]',
); );
if (count) if (count)
count.textContent = t('layers.missions.roster.count', { count.textContent = t('{count} / 30D', {
count: layerState._launches.length, count: layerState._launches.length,
}); });
if (!list) return; if (!list) return;
@@ -301,7 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._launches, layerState._launches,
); );
if (!entries.length) { 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( restoreMissionRosterFocus(
list, list,
focusSnapshot, focusSnapshot,
@@ -314,15 +314,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
list.innerHTML = entries list.innerHTML = entries
.map(({ launch, index }) => { .map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString(); const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date = const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
launch.launchTime?.slice(0, 10) || const provider = launch.provider || t('UNSPECIFIED OPERATOR');
t('layers.missions.roster.dateUnavailable');
const provider =
launch.provider || t('layers.missions.roster.unspecifiedOperator');
const label = parts.overlays const label = parts.overlays
.shortMissionLabel(launch.name, 27) .shortMissionLabel(launch.name, 27)
.toUpperCase(); .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(''); .join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
@@ -379,7 +376,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField( setMissionPanelField(
'[data-mission-distance]', '[data-mission-distance]',
Number.isFinite(altitudeM) Number.isFinite(altitudeM)
? t('layers.missions.telemetry.km', { ? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, { value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}), }),
@@ -392,7 +389,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField( setMissionPanelField(
'[data-mission-speed]', '[data-mission-speed]',
Number.isFinite(speedMps) Number.isFinite(speedMps)
? t('layers.missions.telemetry.kmPerSecond', { ? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, { value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
@@ -473,16 +470,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail'; layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute( layerState._missionPanel.setAttribute(
'aria-label', '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 layerState._missionPanel
.querySelector('.mission-action-row') .querySelector('.mission-action-row')
.insertAdjacentHTML( .insertAdjacentHTML(
'beforeend', 'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden> `<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" 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('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></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>`, </div>`,
); );
host.appendChild(layerState._missionPanel); host.appendChild(layerState._missionPanel);
+1 -1
View File
@@ -522,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
return { return {
lat: Cesium.Math.toDegrees(lat), lat: Cesium.Math.toDegrees(lat),
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), 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; : 0;
return { return {
orbit: orbitName orbit: orbitName
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}` ? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
: null, : null,
ascent: ascent:
suppliedTrajectoryPoints > 1 suppliedTrajectoryPoints > 1
? t('layers.missions.rows.suppliedTrajectory') ? t('SUPPLIED TRAJECTORY POINTS')
: replayAvailable : replayAvailable
? t('layers.missions.rows.reconstructedEstimate') ? t('RECONSTRUCTED ESTIMATE')
: t('layers.missions.rows.unavailable'), : t('UNAVAILABLE'),
replayAvailable: Boolean(replayAvailable), replayAvailable: Boolean(replayAvailable),
}; };
} }
@@ -198,7 +198,7 @@ export function createPolicyHelpers({
function formatMissionEventTime(launchTime) { function formatMissionEventTime(launchTime) {
const date = new Date(launchTime); const date = new Date(launchTime);
if (!launchTime || !Number.isFinite(date.getTime())) 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`; 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; if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable; button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable; button.disabled = !replayAvailable;
button.textContent = t('layers.missions.replay.start'); button.textContent = t('REPLAY ASCENT');
button.classList.remove('active'); button.classList.remove('active');
button.setAttribute('aria-pressed', String(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) { if (transport) {
transport.hidden = !active; transport.hidden = !active;
transport.classList.toggle( transport.classList.toggle(
@@ -219,13 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ'; toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute( toggleButton.setAttribute(
'aria-label', 'aria-label',
layerState._replayPaused layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause'),
); );
toggleButton.title = layerState._replayPaused toggleButton.title = layerState._replayPaused
? t('layers.missions.replay.resume') ? t('Resume replay')
: t('layers.missions.replay.pause'); : t('Pause replay');
} }
} }
} }
@@ -236,19 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
); );
if (!transport || !layerState._replayCameraLaunchId) return; if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive const phase = state.countdownActive
? t('layers.missions.replay.phaseCountdown', { ? t('T minus {seconds}', {
seconds: state.countdownSeconds, seconds: state.countdownSeconds,
}) })
: state.preCountdownActive : state.preCountdownActive
? t('layers.missions.replay.phasePreparing') ? t('Preparing launch site')
: state.elapsedSinceStart < 1 : state.elapsedSinceStart < 1
? t('layers.missions.replay.phaseLiftoff') ? t('Liftoff')
: state.ascending : state.ascending
? t('layers.missions.replay.phaseAscent') ? t('Ascent replay')
: t('layers.missions.replay.phaseOrbit'); : t('Orbit replay');
transport.setAttribute( transport.setAttribute(
'aria-label', '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) { 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`; return `${Math.round(altitudeFt)} ft`;
} }
+4 -6
View File
@@ -220,7 +220,7 @@ export function createTracking({
function _buildTrackedLabel(info, icao24) { function _buildTrackedLabel(info, icao24) {
const stale = const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ` · ${t('layers.status.stale')}` ? ` · ${t('STALE')}`
: ''; : '';
const callsign = const callsign =
(parts.queries._toCleanText(info?.callsign) || (parts.queries._toCleanText(info?.callsign) ||
@@ -230,14 +230,12 @@ export function createTracking({
// what the Easter egg replaces the real type with. // what the Easter egg replaces the real type with.
const type = tr3bTypeLabel( const type = tr3bTypeLabel(
icao24, icao24,
parts.queries._toCleanText(info?.type) || t('layers.readout.typeUnknown'), parts.queries._toCleanText(info?.type) || t('Type unknown'),
); );
const registration = const registration =
parts.queries._toCleanText(info?.registration) || parts.queries._toCleanText(info?.registration) || t('Reg unknown');
t('layers.readout.regUnknown');
const operator = const operator =
parts.queries._toCleanText(info?.operator) || parts.queries._toCleanText(info?.operator) || t('Operator unknown');
t('layers.readout.operatorUnknown');
const altitude = parts.queries._formatAltitude(info?.altitudeFt); const altitude = parts.queries._formatAltitude(info?.altitudeFt);
const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null; const speedKt = info?.speedMps ? Math.round(info.speedMps * 1.944) : null;
const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude; const tail = speedKt ? `${altitude} · ${speedKt} kt` : altitude;
+9 -9
View File
@@ -70,20 +70,20 @@ export function createCategories({
function buildRadioCategories(stations) { function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : []; const rows = Array.isArray(stations) ? stations : [];
const categories = [ const categories = [
{ id: 'all', label: t('layers.radio.filterAll') }, { id: 'all', label: t('All') },
{ id: 'news', label: t('layers.radio.category.news') }, { id: 'news', label: t('News') },
{ id: 'talk', label: t('layers.radio.category.talk') }, { id: 'talk', label: t('Talk') },
{ id: 'weather', label: t('layers.radio.category.weather') }, { id: 'weather', label: t('Weather / Emergency') },
{ id: 'public-safety', label: t('layers.radio.category.publicSafety') }, { id: 'public-safety', label: t('Public Safety') },
{ {
id: 'aviation-marine', id: 'aviation-marine',
label: t('layers.radio.category.aviationMarine'), label: t('Aviation / Marine'),
}, },
{ {
id: 'traffic-transit', 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) { for (const [genre, label] of MUSIC_GENRES) {
@@ -92,7 +92,7 @@ export function createCategories({
categories.push({ id, label }); 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) => ({ return categories.map((category) => ({
...category, ...category,
color: parts.model.radioCategoryColor(category.id), color: parts.model.radioCategoryColor(category.id),
+13 -8
View File
@@ -494,22 +494,27 @@ export function createControls({ state: layerState, services, parts, source }) {
const active = const active =
layerState._params.catalog === 'dense' && layerState._params.catalog === 'dense' &&
layerState._denseStatus === 'ready'; layerState._denseStatus === 'ready';
let title = t('layers.satellites.denseTitleAdd'); let title = t(
if (loading) title = t('layers.satellites.denseTitleLoading'); 'Add the full Starlink broadband shell (thousands of extra points)',
);
if (loading) title = t('Loading the Starlink shell…');
else if (failed) else if (failed)
title = t('layers.satellites.denseTitleFailed', { title = t('Starlink {detail} — click to retry', {
detail: layerState._denseError || t('layers.satellites.loadFailed'), 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 { return {
chips: [ chips: [
{ {
id: 'catalog', id: 'catalog',
label: loading label: loading
? t('layers.satellites.denseChipLoading') ? t('DENSE ···')
: failed : failed
? t('layers.satellites.denseChipFailed') ? t('DENSE ✕')
: t('layers.satellites.denseChip'), : t('DENSE'),
active, active,
busy: loading, busy: loading,
disabled: loading, disabled: loading,
+1 -1
View File
@@ -12,7 +12,7 @@ export function createIngestion({ state, parts, source }) {
state._loading = true; state._loading = true;
state._error = null; state._error = null;
state._loadingLabel = t('layers.meta.loading'); state._loadingLabel = t('loading...');
// Ownership token (the militaryAwareness activationId pattern): a // Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this // 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 // 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, mode,
error: null, error: null,
loadingLabel: fetching loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource }) ? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0 : coveragePct > 0
? t('layers.traffic.liveRoadsFlowHidden', { ? t('LIVE · Roads: {source} · Roads without flow hidden', {
source: roadSource, source: roadSource,
}) })
: t('layers.traffic.liveRoadsNoFlowInView', { : t('LIVE · Roads: {source} · No flow roads in view', {
source: roadSource, source: roadSource,
}), }),
}; };
@@ -276,13 +276,13 @@ export function createModel({ state: layerState, services, parts, source }) {
mode, mode,
error: null, error: null,
loadingLabel: fetching loadingLabel: fetching
? t('layers.traffic.syncingRoads', { source: roadSource }) ? t('Syncing flow · Roads: {source}', { source: roadSource })
: coveragePct > 0 : coveragePct > 0
? t('layers.traffic.roadsFlowLive', { ? t('LIVE · Roads: {source} · Flow {percent}%', {
source: roadSource, source: roadSource,
percent: coveragePct, percent: coveragePct,
}) })
: t('layers.traffic.roadsFlowSimulated', { : t('SIMULATED · Roads: {source} · Flow {percent}%', {
source: roadSource, source: roadSource,
percent: coveragePct, percent: coveragePct,
}), }),
@@ -293,16 +293,24 @@ export function createModel({ state: layerState, services, parts, source }) {
mode, mode,
error: null, error: null,
loadingLabel: fetching loadingLabel: fetching
? t('layers.traffic.loadingSyncing') ? t(
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
)
: coveragePct > 0 : coveragePct > 0
? coveragePct < 100 ? coveragePct < 100
? t('layers.traffic.liveCoverageUnmatched', { ? t(
percent: coveragePct, 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
}) {
: t('layers.traffic.liveCoverage', { percent: coveragePct,
percent: coveragePct, },
}) )
: t('layers.traffic.simulatedNoMatches'), : 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 // 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, mode,
error: null, error: null,
loadingLabel: statusUnavailable loadingLabel: statusUnavailable
? t('layers.traffic.simUnavailable') ? t('SIMULATED — traffic service unreachable')
: t('layers.traffic.simKeyless'), : t('SIMULATED — add TomTom key for live'),
}; };
} }
+11 -11
View File
@@ -21,13 +21,13 @@ export function createCards({
const stale = (record.missedRefreshes || 0) > 0; const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active'); el.classList.add('active');
el.textContent = [ el.textContent = [
t('layers.vessel.hudName', { name: trimHudValue(record.name, 32) }), t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('layers.vessel.hudTypeLine', { t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('layers.vessel.fallback'), 24), type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed), speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course), 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'); ].join('\n');
} }
@@ -35,7 +35,7 @@ export function createCards({
const el = document.getElementById('hud-ais-vessel'); const el = document.getElementById('hud-ais-vessel');
if (!el) return; if (!el) return;
el.classList.remove('active'); el.classList.remove('active');
el.textContent = t('layers.vessel.hudIdle'); el.textContent = t('AIS: --');
} }
function trimHudValue(value, maxLength) { function trimHudValue(value, maxLength) {
@@ -90,7 +90,7 @@ export function createCards({
const direction = record.heading ?? record.course; const direction = record.heading ?? record.course;
const details = [ const details = [
[ [
vesselTypeShort(record) || t('layers.vessel.fallback'), vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed), formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '), ].join(' · '),
@@ -99,7 +99,7 @@ export function createCards({
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`); if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0; const stale = (record.missedRefreshes || 0) > 0;
details.push( details.push(
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('layers.status.stale')}` : ''}`, `MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
); );
return { return {
id: vesselOverlayEntryId(record), id: vesselOverlayEntryId(record),
@@ -156,7 +156,7 @@ export function createCards({
function displayVesselName(record) { function displayVesselName(record) {
const name = String(record.name || '').trim(); const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name; 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) { function formatSpeed(speed) {
@@ -168,10 +168,10 @@ export function createCards({
} }
function formatPositionTime(record) { function formatPositionTime(record) {
if (!record.lastPositionUtc) return t('layers.vessel.posLive'); if (!record.lastPositionUtc) return t('POS: LIVE');
const date = new Date(record.lastPositionUtc); const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return t('layers.vessel.posLive'); if (Number.isNaN(date.getTime())) return t('POS: LIVE');
return t('layers.vessel.posAt', { time: date.toISOString().slice(11, 19) }); return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
} }
return { return {
updateSelectedVesselHud, updateSelectedVesselHud,
+3 -3
View File
@@ -38,7 +38,7 @@ export function createLifecycle({
state.feed.firstConnectStartedAt = startedAt; state.feed.firstConnectStartedAt = startedAt;
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS; state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.feed.error = null; 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); scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
} }
@@ -60,8 +60,8 @@ export function createLifecycle({
state.feed.firstConnectPhase = 'unavailable'; state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = ''; state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt state.feed.error = state.feed.lastMessageAt
? t('layers.vessel.awaitingPositions') ? t('awaiting usable AIS positions…')
: t('layers.vessel.awaitingFirstMessage'); : t('awaiting first AIS message…');
state.feed.stale = state.feed.count > 0; state.feed.stale = state.feed.count > 0;
}, delayMs); }, delayMs);
} }
+8 -10
View File
@@ -1,14 +1,14 @@
import * as Cesium from 'cesium'; 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 // 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 // server-side source builds), so it writes stable English feed labels into
// state. Localize those known labels here, at the presentation read; anything // state. Localize those known labels here, at the presentation read; anything
// else (a bespoke manager label) passes through verbatim. // else (a bespoke manager label) passes through verbatim.
const FEED_STATE_LABEL_KEYS = Object.freeze({ const FEED_STATE_LABEL_KEYS = Object.freeze({
'refreshing...': 'layers.meta.refreshing', 'refreshing...': msg('refreshing...'),
'loading...': 'layers.meta.loading', 'loading...': msg('loading...'),
'awaiting first AIS position…': 'layers.vessel.awaitingFirstPosition', 'awaiting first AIS position…': msg('awaiting first AIS position…'),
}); });
function localizeFeedLabel(raw) { 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 (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
if (AIS_HEALTHY_STATUSES.has(status)) { if (AIS_HEALTHY_STATUSES.has(status)) {
return payload?.lastMessageAt return payload?.lastMessageAt
? t('layers.vessel.awaitingPositions') ? t('awaiting usable AIS positions…')
: t('layers.vessel.awaitingFirstMessage'); : t('awaiting first AIS message…');
} }
if (!status) return null; if (!status) return null;
const detail = const detail =
@@ -158,9 +158,7 @@ export function createQueries({
error: emptyCoverage error: emptyCoverage
? null ? null
: deriveAisFeedError(payload, acceptedRowCount) || : deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 (acceptedRowCount === 0 ? t('awaiting usable AIS positions…') : null),
? t('layers.vessel.awaitingPositions')
: null),
}; };
} }
@@ -532,7 +530,7 @@ export function createQueries({
lastUpdate: state.feed.lastUpdate, lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition, loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition loadingLabel: waitingForFirstPosition
? t('layers.vessel.awaitingFirstPosition') ? t('awaiting first AIS position…')
: localizeFeedLabel(state.feed.loadingLabel), : localizeFeedLabel(state.feed.loadingLabel),
error: state.feed.error, error: state.feed.error,
stale: state.feed.stale, stale: state.feed.stale,
+3 -3
View File
@@ -5,7 +5,7 @@ import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosu
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { t } from './i18n/index.js'; import { msg, t } from './i18n/index.js';
// Exercise the installed event routes and central close method, without WebGL. // Exercise the installed event routes and central close method, without WebGL.
const source = readShellSource(); const source = readShellSource();
@@ -108,9 +108,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.clearTimeout = (id) => timers.delete(id); window.clearTimeout = (id) => timers.delete(id);
window.performance = { now: () => now }; window.performance = { now: () => now };
document.defaultView = window; 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()}});`)( `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 saves = [];
const claims = []; 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 // Stack labels are provider/dataset names (keep-English); the availability
// sentence around them is copy and localizes here at the model boundary. // sentence around them is copy and localizes here at the model boundary.
const fallbackReason = requiresIon const fallbackReason = requiresIon
? t('setup.keySetup.requirement', { ? t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'), envVars: keySetupRequirementEnvVars('cesium-ion'),
}) })
: t('setup.mapStack.unavailableReason', { : t('{label} is unavailable', {
label: label || t('setup.mapStack.fallbackName'), label: label || t('This map stack'),
}); });
const unavailableHint = available const unavailableHint = available
? '' ? ''
@@ -126,7 +126,7 @@ export function renderMapStackChips(
if (!model.available) { if (!model.available) {
chip.setAttribute( chip.setAttribute(
'aria-label', 'aria-label',
t('setup.mapStack.unavailableAriaLabel', { t('{label} unavailable: {hint}', {
label: model.label, label: model.label,
hint: model.unavailableHint, hint: model.unavailableHint,
}), }),
+1 -1
View File
@@ -16,5 +16,5 @@ export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials) if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network"; return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
const envVars = keySetupRequirementEnvVars('google-maps'); 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, descriptor,
available: !descriptor.requiresIon || hasIon, available: !descriptor.requiresIon || hasIon,
unavailableReason: descriptor.requiresIon unavailableReason: descriptor.requiresIon
? t('setup.keySetup.requirement', { ? t('Needs {envVars} — add it in Provider Settings', {
envVars: keySetupRequirementEnvVars('cesium-ion'), envVars: keySetupRequirementEnvVars('cesium-ion'),
}) })
: null, : null,
+20 -18
View File
@@ -26,7 +26,7 @@ import {
getSceneAppendRecipeById, getSceneAppendRecipeById,
} from './recipes.js'; } from './recipes.js';
import { sceneLayerPlan, sceneRequiresContextModeExit } from './scenePolicy.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 { createSceneDataPacks } from './dataPacks/controller.js';
import { createSceneInteractions } from './interactions.js'; import { createSceneInteractions } from './interactions.js';
import { createSceneSharing } from './sharing.js'; import { createSceneSharing } from './sharing.js';
@@ -67,11 +67,11 @@ import {
// boundary + persisted data); only the presentation of an UNRENAMED built-in // boundary + persisted data); only the presentation of an UNRENAMED built-in
// localizes, so an operator's renamed scene is never masked by the catalog. // localizes, so an operator's renamed scene is never masked by the catalog.
const RECIPE_TITLE_KEYS = Object.freeze({ const RECIPE_TITLE_KEYS = Object.freeze({
'flights-radar': 'setup.scenes.recipe.flightsRadar', 'flights-radar': msg('Global Flights Radar'),
'orbital-watch': 'setup.scenes.recipe.orbitalWatch', 'orbital-watch': msg('Orbital Watch'),
'thermal-threats': 'setup.scenes.recipe.thermalThreats', 'thermal-threats': msg('Thermal Threat Board'),
'city-overload': 'setup.scenes.recipe.cityOverload', 'city-overload': msg('City Overload'),
'omniscience-pullback': 'setup.scenes.recipe.omnisciencePullback', 'omniscience-pullback': msg('Omniscience Pullback'),
}); });
/** /**
@@ -198,7 +198,7 @@ export class SceneDirector {
this._presentation = { this._presentation = {
status: this._storageReadError status: this._storageReadError
? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.' ? 'Saved project could not be read; storage preserved. Import a valid file to resume saving.'
: t('setup.scenes.statusReady'), : t('Ready'),
progress: 0, progress: 0,
runtime: '', runtime: '',
playbackActive: false, playbackActive: false,
@@ -396,7 +396,7 @@ export class SceneDirector {
_shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) { _shotOutcome(type, scene, shot, index = scene.shots.indexOf(shot)) {
if (type === 'shot-loaded') if (type === 'shot-loaded')
this._presentation.status = t('setup.scenes.status.loaded', { this._presentation.status = t('Loaded: {scene} / {shot}', {
scene: scene.title, scene: scene.title,
shot: shot.title, shot: shot.title,
}); });
@@ -954,14 +954,14 @@ export class SceneDirector {
const camera = this.styleManager.getCameraState(); const camera = this.styleManager.getCameraState();
if (!camera) { if (!camera) {
this._updateStatus(t('setup.scenes.status.captureCameraNotReady')); this._updateStatus(t('Cannot capture shot: camera not ready'));
return; return;
} }
const shot = normalizeShot( const shot = normalizeShot(
{ {
id: uid('shot'), id: uid('shot'),
title: t('setup.scenes.status.shotTitleDefault', { title: t('Shot {n}', {
n: scene.shots.length + 1, n: scene.shots.length + 1,
}), }),
durationSec: DEFAULT_SHOT_DURATION_SEC, durationSec: DEFAULT_SHOT_DURATION_SEC,
@@ -978,7 +978,7 @@ export class SceneDirector {
this._saveProject(); this._saveProject();
this._shotOutcome('shot-captured', scene, shot); this._shotOutcome('shot-captured', scene, shot);
this._updateStatus( this._updateStatus(
t('setup.scenes.status.captured', { t('Captured: {scene} / {shot}', {
scene: scene.title, scene: scene.title,
shot: shot.title, shot: shot.title,
}), }),
@@ -995,7 +995,7 @@ export class SceneDirector {
const shot = scene.shots.find((item) => item.id === this._selectedShotId); const shot = scene.shots.find((item) => item.id === this._selectedShotId);
if (!shot) { if (!shot) {
this._updateStatus(t('setup.scenes.status.selectShotFirst')); this._updateStatus(t('Select a shot first'));
return; return;
} }
@@ -1011,7 +1011,7 @@ export class SceneDirector {
this._saveProject(); this._saveProject();
this._shotOutcome('shot-updated', scene, shot); this._shotOutcome('shot-updated', scene, shot);
this._updateStatus( this._updateStatus(
t('setup.scenes.status.updated', { t('Updated: {scene} / {shot}', {
scene: scene.title, scene: scene.title,
shot: shot.title, shot: shot.title,
}), }),
@@ -1610,7 +1610,7 @@ export class SceneDirector {
this._claimingCamera = false; this._claimingCamera = false;
} }
if (claimed === false) { if (claimed === false) {
this._updateStatus(t('setup.scenes.status.cameraUnavailable')); this._updateStatus(t('Camera unavailable — exit cockpit first'));
return false; return false;
} }
return true; return true;
@@ -1745,7 +1745,7 @@ export class SceneDirector {
if (!queue.length) return { started: false, reason: 'scene-complete' }; if (!queue.length) return { started: false, reason: 'scene-complete' };
} }
if (!queue.length) { if (!queue.length) {
this._updateStatus(t('setup.scenes.status.noShotsToRun')); this._updateStatus(t('No shots to run'));
return { started: false, reason: 'no-shots' }; return { started: false, reason: 'no-shots' };
} }
@@ -1829,7 +1829,7 @@ export class SceneDirector {
}); });
} catch (error) { } catch (error) {
this._updateStatus( this._updateStatus(
t('setup.scenes.status.runError', { t('Error: {message}', {
message: error.message || 'run failed', message: error.message || 'run failed',
}), }),
); );
@@ -2006,7 +2006,7 @@ export class SceneDirector {
link.click(); link.click();
link.remove(); link.remove();
URL.revokeObjectURL(url); 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 }); this._publish({ type: 'project-exported', project: this._project });
} }
@@ -2268,7 +2268,9 @@ export class SceneDirector {
`[Scenes] Could not exit ${mode}:`, `[Scenes] Could not exit ${mode}:`,
result.error || 'unknown reason', 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', { this._logEvent('context_mode_exit_failed', {
mode, mode,
error: result.error || null, error: result.error || null,
+2 -2
View File
@@ -16,7 +16,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
director._renderSceneSelect(); director._renderSceneSelect();
director._renderShotList(); director._renderShotList();
director._updateStatus( director._updateStatus(
t('setup.scenes.status.runningShot', { t('Running {index}/{total}: {scene} / {shot}', {
index: index + 1, index: index + 1,
total, total,
scene: scene.title, scene: scene.title,
@@ -78,7 +78,7 @@ export function createScenePlaybackAdapter(director, defaultShotDurationSec) {
}, },
complete() { complete() {
director._setProgress(1); director._setProgress(1);
director._updateStatus(t('setup.scenes.status.runComplete')); director._updateStatus(t('Scene run complete'));
director._logEvent('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], plugins: [plugin],
googleApiKey: 'browser-fixture', googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture', cesiumToken: 'ion-fixture',
defaultLocale: 'fr',
secondaryLocale: 'en',
}); });
// The Host check runs before every other plugin's middleware. // The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check'); 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, { assert.deepEqual(config.define, {
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"', 'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-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': '""', 'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
}); });
assert.equal( assert.equal(
+22 -30
View File
@@ -38,7 +38,7 @@ import { ShellFeedback } from './shellFeedback.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import { getLocale, persistLocaleAndReload, t } from '../i18n/index.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. * 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 * Renders the command-dock locale switch: one button per shipped locale,
* configurable-pair stage): one button per locale availableLocales() * labelled with its code and named in its own language. A click stores the
* yields — the configured GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, * choice and reloads, so nothing re-renders live.
* 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).
* @returns {void} * @returns {void}
*/ */
_initLocaleSelector() { _initLocaleSelector() {
@@ -723,13 +717,14 @@ export class StyleManager extends ShellFacade {
); );
if (!container) return; if (!container) return;
const active = getLocale(); const active = getLocale();
for (const locale of availableLocales()) { for (const locale of LOCALES) {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'dock-locale-btn'; button.className = 'dock-locale-btn';
button.dataset.locale = locale; button.dataset.locale = locale;
button.textContent = locale.toUpperCase(); 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; const isActive = locale === active;
button.classList.toggle('active', isActive); button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', String(isActive)); button.setAttribute('aria-pressed', String(isActive));
@@ -770,15 +765,15 @@ export class StyleManager extends ShellFacade {
_applyRuntimeStaticHeaderText() { _applyRuntimeStaticHeaderText() {
this._setHeaderText( this._setHeaderText(
document.querySelector('#control-panel .panel-title'), document.querySelector('#control-panel .panel-title'),
t('cockpit.presets.title'), t('VISUAL PRESETS'),
); );
this._setHeaderText( this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'), document.querySelector('#location-bar .location-toolbar-label'),
t('cockpit.location.toolbarLabel'), t('LOCATION'),
); );
this._setHeaderText( this._setHeaderText(
document.querySelector('#pp-toggles .pp-header-label'), 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 cockpit context kicker ('CONTACT') is visible on the map itself, and
// the briefing kicker shares its element with the live-dot <i>; both are // 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. // text node keeps renderBriefPage's leading-space convention.
this._setHeaderText( this._setHeaderText(
document.querySelector('.cockpit-context-kicker'), document.querySelector('.cockpit-context-kicker'),
t('cockpit.context.kicker'), t('CONTACT'),
); );
// The phase-2 key-only standby description: one span holds BOTH mode // The phase-2 key-only standby description: one span holds BOTH mode
// descriptions split by a literal <br>, so no attribute write was possible // descriptions split by a literal <br>, so no attribute write was possible
@@ -797,14 +792,18 @@ export class StyleManager extends ShellFacade {
); );
if (standbyDescription) { if (standbyDescription) {
standbyDescription.replaceChildren( standbyDescription.replaceChildren(
document.createTextNode(t('cockpit.context.standbyContactsDesc')), document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'), document.createElement('br'),
document.createTextNode(t('cockpit.context.standbyMissionsDesc')), document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
); );
} }
const briefKicker = document.querySelector('#cockpit-brief-kicker'); const briefKicker = document.querySelector('#cockpit-brief-kicker');
if (briefKicker) { 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 // runtime write instead. The locale resolves before UI init and never
// changes without a reload, so an init-time write is sufficient. // changes without a reload, so an init-time write is sufficient.
if (this._mapSourceLabel) if (this._mapSourceLabel)
this._mapSourceLabel.textContent = t('cockpit.presets.mapSourceLabel'); this._mapSourceLabel.textContent = t('MAP SOURCE');
this._mapStackChips.setAttribute( this._mapStackChips.setAttribute('aria-label', t('Map source'));
'aria-label',
t('cockpit.presets.mapSourceChipsAriaLabel'),
);
this._mapSourceControls?.destroy(); this._mapSourceControls?.destroy();
this._mapSourceControls = createMapSourceControls({ this._mapSourceControls = createMapSourceControls({
@@ -1149,7 +1145,7 @@ export class StyleManager extends ShellFacade {
const { cctvLayer } = this.services; const { cctvLayer } = this.services;
if (this._disposed) return false; if (this._disposed) return false;
if (!this._dataManager || !this._dataManager.layers?.has('cctv')) { if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
this._showToast(t('cockpit.cctv.toastLayerUnavailable')); this._showToast(t('CCTV layer unavailable'));
return false; return false;
} }
const enabled = this._dataManager.isEnabled('cctv'); const enabled = this._dataManager.isEnabled('cctv');
@@ -1598,11 +1594,7 @@ export class StyleManager extends ShellFacade {
this._lifetime.listen(this._shareBtn, 'click', async () => { this._lifetime.listen(this._shareBtn, 'click', async () => {
const success = await this.shareLinkManager.copyLink(); const success = await this.shareLinkManager.copyLink();
if (!this._disposed) if (!this._disposed)
this._showToast( this._showToast(success ? t('Link copied!') : t('Copy failed'));
success
? t('cockpit.share.toastCopied')
: t('cockpit.share.toastCopyFailed'),
);
}); });
} }
+1 -1
View File
@@ -145,7 +145,7 @@ export function _initCctvPanel() {
}, },
{ origin: 'user' }, { origin: 'user' },
); );
this.actions.showToast(t('cockpit.cctv.toastCalibrationSaved')); this.actions.showToast(t('CCTV calibration saved'));
}); });
this.listen(this._cctvCalibResetBtn, 'click', () => { this.listen(this._cctvCalibResetBtn, 'click', () => {
+2 -4
View File
@@ -83,7 +83,7 @@ export function _resetCctvCalibration() {
}, },
{ origin: 'user' }, { origin: 'user' },
); );
this.actions.showToast(t('cockpit.cctv.toastCalibrationReset')); this.actions.showToast(t('CCTV calibration reset'));
} }
export function _beginCctvCalValueEdit(chip) { export function _beginCctvCalValueEdit(chip) {
@@ -164,9 +164,7 @@ export function _syncCctvCalReadout(enabled, activeCamera) {
if (this._cctvAdjustBtn) { if (this._cctvAdjustBtn) {
const adjustOn = !!this._cctvState?.calibrationMode; const adjustOn = !!this._cctvState?.calibrationMode;
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate); this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
this._cctvAdjustBtn.textContent = adjustOn this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST');
? t('layers.cctv.adjustOn')
: t('layers.cctv.adjustLabel');
this._cctvAdjustBtn.disabled = !canCalibrate; this._cctvAdjustBtn.disabled = !canCalibrate;
} }
if (this._cctvCalReadout) { if (this._cctvCalReadout) {
+3 -3
View File
@@ -88,19 +88,19 @@ export function _settleCctvFrame(token, src, ok) {
export function _syncCctvSourceBadge(activeCamera, enabled) { export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return; if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) { if (!enabled || !activeCamera) {
this._cctvSourceBadge.textContent = t('layers.cctv.sourceUnknown'); this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
this._cctvSourceBadge.dataset.frameState = 'idle'; this._cctvSourceBadge.dataset.frameState = 'idle';
return; return;
} }
const hasDisplayedFrame = const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame'); this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) { 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'; this._cctvSourceBadge.dataset.frameState = 'loading';
return; return;
} }
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) { 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'; this._cctvSourceBadge.dataset.frameState = 'error';
return; 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'; import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) { export function _calBadgeLabel(badge) {
switch (badge) { switch (badge) {
case 'calibrated': case 'calibrated':
return t('cockpit.cctv.calBadgeCalibrated'); return t('CALIBRATED');
case 'curated': case 'curated':
return t('cockpit.cctv.calBadgeCurated'); return t('CURATED');
case 'raw-prior': case 'raw-prior':
return t('cockpit.cctv.calBadgeRawPrior'); return t('RAW PRIOR');
default: default:
return '--'; return '--';
} }
@@ -53,9 +53,7 @@ export function _renderCctvState(state) {
if (this._cctvEnableBtn) { if (this._cctvEnableBtn) {
this._cctvEnableBtn.classList.toggle('active', enabled); this._cctvEnableBtn.classList.toggle('active', enabled);
this._cctvEnableBtn.textContent = enabled this._cctvEnableBtn.textContent = enabled ? t('CCTV ON') : t('CCTV OFF');
? t('layers.cctv.toggleOn')
: t('layers.cctv.toggleOff');
} }
if (this._cctvSelect) { if (this._cctvSelect) {
@@ -103,10 +101,10 @@ export function _renderCctvState(state) {
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off'); this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent = this._cctvCoverageBtn.textContent =
mode === 'viewshed' mode === 'viewshed'
? t('cockpit.cctv.coverageViewshedOn') ? t('VIEWSHED ON')
: mode === 'on' : mode === 'on'
? t('layers.cctv.coverageOn') ? t('COVERAGE ON')
: t('layers.cctv.coverageOff'); : t('COVERAGE OFF');
this._cctvCoverageBtn.disabled = !enabled; this._cctvCoverageBtn.disabled = !enabled;
} }
@@ -114,8 +112,8 @@ export function _renderCctvState(state) {
const autoHop = !!state?.autoHop; const autoHop = !!state?.autoHop;
this._cctvAutoHopBtn.classList.toggle('active', autoHop); this._cctvAutoHopBtn.classList.toggle('active', autoHop);
this._cctvAutoHopBtn.textContent = autoHop this._cctvAutoHopBtn.textContent = autoHop
? t('layers.cctv.autoHopOn') ? t('AUTO HOP ON')
: t('layers.cctv.autoHopOff'); : t('AUTO HOP OFF');
this._cctvAutoHopBtn.disabled = !enabled; this._cctvAutoHopBtn.disabled = !enabled;
} }
@@ -123,8 +121,8 @@ export function _renderCctvState(state) {
const showProjection = state?.showProjection !== false; const showProjection = state?.showProjection !== false;
this._cctvProjectionBtn.classList.toggle('active', showProjection); this._cctvProjectionBtn.classList.toggle('active', showProjection);
this._cctvProjectionBtn.textContent = showProjection this._cctvProjectionBtn.textContent = showProjection
? t('layers.cctv.projectionOn') ? t('PROJECTION ON')
: t('layers.cctv.projectionOff'); : t('PROJECTION OFF');
this._cctvProjectionBtn.disabled = !enabled; this._cctvProjectionBtn.disabled = !enabled;
} }
@@ -139,8 +137,8 @@ export function _renderCctvState(state) {
const badge = activeCamera?.calBadge || null; const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty; const dirty = !!activeCamera?.calDirty;
this._cctvQualityChip.textContent = dirty this._cctvQualityChip.textContent = dirty
? t('cockpit.cctv.calChipEdited') ? t('CAL · EDITED (UNSAVED)')
: t('cockpit.cctv.calChipTemplate', { : t('CAL · {badge}', {
badge: this._calBadgeLabel(badge), badge: this._calBadgeLabel(badge),
}); });
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || ''; this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
@@ -163,25 +161,36 @@ export function _renderCctvState(state) {
? this._calBadgeLabel(activeCamera.calBadge) ? this._calBadgeLabel(activeCamera.calBadge)
: ''; : '';
const projLabel = const projLabel =
state?.showProjection !== false state?.showProjection !== false ? t('MONITOR') : t('OFF');
? t('cockpit.cctv.metaProjectionMonitor') this._cctvMeta.textContent = t(
: t('cockpit.cctv.metaProjectionOff'); '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
this._cctvMeta.textContent = t('cockpit.cctv.metaTemplate', { {
city: activeCamera.city, city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`, heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`, fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM), range: Math.round(activeCamera.rangeM),
projection: projLabel, projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '', calBadge: calBadge ? ` · ${calBadge}` : '',
provider, provider,
status: `${credit}${statusMsg}`, status: `${credit}${statusMsg}`,
}); },
);
} else if (cameras.length > 0) { } else if (cameras.length > 0) {
this._cctvMeta.textContent = enabled this._cctvMeta.textContent = enabled
? t('cockpit.cctv.metaCamerasClick', { count: cameras.length }) ? tn(
: t('cockpit.cctv.metaCamerasEnable', { count: cameras.length }); 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 { } 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._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) { export function _typeCctvSummary(text) {
if (this.destroyed || !this._cctvSummary) return; if (this.destroyed || !this._cctvSummary) return;
const nextText = const nextText = String(text || '').trim() || t('No summary available.');
String(text || '').trim() || t('cockpit.cctv.summaryNoneAvailable');
if (nextText === this._lastCctvSummaryText) return; if (nextText === this._lastCctvSummaryText) return;
this._lastCctvSummaryText = nextText; this._lastCctvSummaryText = nextText;
@@ -267,10 +278,7 @@ export function _updateCctvSyncChip(loading, enabled) {
clearTimeout(this._cctvChipHideTimer); clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = null; this._cctvChipHideTimer = null;
this._cctvChipWasBusy = true; this._cctvChipWasBusy = true;
this.actions.setSplitFlapText( this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
this._cctvSyncLabel,
t('cockpit.cctv.syncLoadingFrames'),
);
// The counter is left plain on purpose: it ticks every few 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. // during a grid load, and flapping it would read as a slot machine.
this._cctvSyncProgress.textContent = `${loaded}/${total}`; this._cctvSyncProgress.textContent = `${loaded}/${total}`;
@@ -281,10 +289,7 @@ export function _updateCctvSyncChip(loading, enabled) {
if (this._cctvChipWasBusy && enabled && total > 0) { if (this._cctvChipWasBusy && enabled && total > 0) {
// Load just completed — flash the final count, then auto-hide. // Load just completed — flash the final count, then auto-hide.
this._cctvChipWasBusy = false; this._cctvChipWasBusy = false;
this.actions.setSplitFlapText( this.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
this._cctvSyncLabel,
t('cockpit.cctv.syncGridReady'),
);
this._cctvSyncProgress.textContent = `${total}/${total}`; this._cctvSyncProgress.textContent = `${total}/${total}`;
this._cctvSyncChip.classList.add('visible'); this._cctvSyncChip.classList.add('visible');
clearTimeout(this._cctvChipHideTimer); clearTimeout(this._cctvChipHideTimer);
+2 -2
View File
@@ -15,8 +15,8 @@ export function bindClearLayersControl(button, clear) {
button.setAttribute( button.setAttribute(
'aria-label', 'aria-label',
busy busy
? t('cockpit.actions.clearLayersBusyAria') ? t('Clearing selected data layers')
: t('shell.actions.clearLayers.ariaLabel'), : t('Clear selected data layers'),
); );
}, },
destroy() { destroy() {
+35 -35
View File
@@ -1,5 +1,5 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */ /** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { t } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
import { import {
COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP, COCKPIT_BRIEF_CYCLE_OFF_HELP,
@@ -15,18 +15,18 @@ import {
// so it returns stable English labels; localize them here, at the cockpit // so it returns stable English labels; localize them here, at the cockpit
// presentation boundary (docs/TRANSLATORS.md). // presentation boundary (docs/TRANSLATORS.md).
const WEATHER_CONDITION_KEYS = Object.freeze({ const WEATHER_CONDITION_KEYS = Object.freeze({
'CONDITIONS UNKNOWN': 'layers.weather.conditionsUnknown', 'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
CLEAR: 'layers.weather.clear', CLEAR: msg('CLEAR'),
'PARTLY CLOUDY': 'layers.weather.partlyCloudy', 'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
OVERCAST: 'layers.weather.overcast', OVERCAST: msg('OVERCAST'),
FOG: 'layers.weather.fog', FOG: msg('FOG'),
DRIZZLE: 'layers.weather.drizzle', DRIZZLE: msg('DRIZZLE'),
RAIN: 'layers.weather.rain', RAIN: msg('RAIN'),
SNOW: 'layers.weather.snow', SNOW: msg('SNOW'),
'RAIN SHOWERS': 'layers.weather.rainShowers', 'RAIN SHOWERS': msg('RAIN SHOWERS'),
'SNOW SHOWERS': 'layers.weather.snowShowers', 'SNOW SHOWERS': msg('SNOW SHOWERS'),
THUNDERSTORM: 'layers.weather.thunderstorm', THUNDERSTORM: msg('THUNDERSTORM'),
'MIXED CONDITIONS': 'layers.weather.mixed', 'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
}); });
export function showBriefPage(index, { manual = false } = {}) { 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 // these t() re-writes are same-value overrides that localize the toggle
// under other locales (byte-identical under 'en'). // under other locales (byte-identical under 'en').
const labelKey = this.briefAutoRotateEnabled const labelKey = this.briefAutoRotateEnabled
? 'cockpit.brief.autoOn' ? msg('CYCLE ON')
: 'cockpit.brief.autoOff'; : msg('CYCLE OFF');
this.briefAutoToggle.textContent = t(labelKey); this.briefAutoToggle.textContent = t(labelKey);
this.briefAutoToggle.setAttribute('aria-label', t(labelKey)); this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
this.briefAutoToggle.title = t( this.briefAutoToggle.title = t(
this.briefAutoRotateEnabled this.briefAutoRotateEnabled
? 'cockpit.brief.autoTitleOn' ? msg(
: 'cockpit.brief.autoTitle', '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 }); if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
@@ -127,7 +131,7 @@ export function stopBriefRotation() {
export function updateLocalPosition(info) { export function updateLocalPosition(info) {
if (!this.localCoordinates) return; if (!this.localCoordinates) return;
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
this.localCoordinates.textContent = t('cockpit.brief.positionUnavailable'); this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
return; return;
} }
const lat = `${Math.abs(info.latitude).toFixed(3)}°${info.latitude >= 0 ? 'N' : 'S'}`; 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.dataset.state = status;
this.newsStatus.textContent = this.newsStatus.textContent =
status === 'loading' status === 'loading'
? t('cockpit.brief.newsAcquiring') ? t('ACQUIRING REGIONAL NEWS')
: t('cockpit.brief.newsUnavailable'); : t('REGIONAL NEWS UNAVAILABLE');
} }
if (status === 'unavailable') this.newsList?.replaceChildren(); if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading') if (this.localPlace && status === 'loading')
this.localPlace.textContent = t('cockpit.brief.localResolving'); this.localPlace.textContent = t('RESOLVING REGION');
if (this.localPlace && status === 'unavailable') if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = t('cockpit.brief.regionUnavailable'); this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(info); this.updateLocalPosition(info);
} }
@@ -226,8 +230,8 @@ export function renderRegionalBrief(payload, info) {
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent = this.newsStatus.textContent =
payload?.newsStatus === 'empty' payload?.newsStatus === 'empty'
? t('cockpit.brief.newsEmpty') ? t('NO RECENT LOCATION MATCHES')
: t('cockpit.brief.newsUnavailable'); : t('REGIONAL NEWS UNAVAILABLE');
} }
if (this.newsList) { if (this.newsList) {
this.newsList.replaceChildren( this.newsList.replaceChildren(
@@ -240,8 +244,8 @@ export function renderRegionalBrief(payload, info) {
const title = document.createElement('strong'); const title = document.createElement('strong');
title.textContent = article.title; title.textContent = article.title;
const metadata = document.createElement('span'); const metadata = document.createElement('span');
metadata.textContent = t('cockpit.brief.articleMetaTemplate', { metadata.textContent = t('{domain} · {age}', {
domain: article.domain || t('cockpit.brief.metadataSourceFallback'), domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt), age: formatCockpitBriefAge(article.publishedAt),
}); });
link.append(title, metadata); link.append(title, metadata);
@@ -252,9 +256,7 @@ export function renderRegionalBrief(payload, info) {
} }
const placeLabel = const placeLabel =
payload?.place?.label || payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
payload?.place?.country ||
t('cockpit.brief.regionUnavailable');
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase(); if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info); this.updateLocalPosition(info);
const weather = payload?.weather; const weather = payload?.weather;
@@ -282,10 +284,10 @@ export function renderRegionalBrief(payload, info) {
} }
if (this.localCloud) { if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? t('cockpit.brief.cloudTemplate', { ? t('CLOUD {pct}%', {
pct: Math.round(weather.cloudCoverPct), pct: Math.round(weather.cloudCoverPct),
}) })
: t('cockpit.brief.cloudUnknown'); : t('CLOUD UNKNOWN');
} }
if (this.localPrecipitation) { if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite( this.localPrecipitation.textContent = Number.isFinite(
@@ -297,10 +299,8 @@ export function renderRegionalBrief(payload, info) {
if (this.signalStream) if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) { if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = t('cockpit.brief.newsSourceLine', { this.briefSource.textContent = t('{source} · LOCATION QUERY', {
source: String( source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
payload?.newsSource || t('cockpit.brief.kickerNews'),
).toUpperCase(),
}); });
} }
this.scheduleContextLayout(); this.scheduleContextLayout();
+19 -19
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../i18n/index.js'; import { t, tn } from '../i18n/index.js';
import { import {
bearingBetweenCoordinates, bearingBetweenCoordinates,
formatCockpitContextScope, formatCockpitContextScope,
@@ -21,8 +21,8 @@ export function updateContext(info, heading) {
this.pushCockpitSignal( this.pushCockpitSignal(
'context-status', 'context-status',
'info', 'info',
t('cockpit.signal.contextStandby'), t('CONTEXT STANDBY'),
t('cockpit.signal.contextStandbyHint'), t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
); );
return; return;
} }
@@ -59,7 +59,7 @@ export function updateContext(info, heading) {
this.contextUncertainty.textContent = this.contextUncertainty.textContent =
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR'; 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR';
this.contextUncertainty.textContent = t( 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 // 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( this.pushCockpitSignal(
'context-status', 'context-status',
'warning', 'warning',
t('cockpit.signal.contactLostTitle', { t('CONTACT LOST · {subject}', {
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT', subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
}), }),
t('cockpit.signal.contactLostDetail'), t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
); );
return; return;
} }
@@ -92,15 +92,15 @@ export function updateContext(info, heading) {
const closestLabel = this.services.formatAwarenessLabel(closest); const closestLabel = this.services.formatAwarenessLabel(closest);
if (this.contextNearestLabel) { if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest this.contextNearestLabel.textContent = closest
? t('cockpit.context.nearestTemplate', { ? t('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(), cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel, contact: closestLabel,
}) })
: t('cockpit.context.nearestEmpty'); : t('NO AVAILABLE EXAMPLE');
this.contextNearestLabel.setAttribute( this.contextNearestLabel.setAttribute(
'aria-label', 'aria-label',
closest && closestLabel === '—' closest && closestLabel === '—'
? t('cockpit.context.nearestUnavailableAria', { ? t('{cohort}, Unavailable', {
cohort: closest.cohort.label, cohort: closest.cohort.label,
}) })
: this.contextNearestLabel.textContent, : this.contextNearestLabel.textContent,
@@ -153,24 +153,24 @@ export function updateContext(info, heading) {
else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD'; else if (Math.abs(relative) < 8) this.contextBearing.textContent = 'AHEAD';
else else
this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`; this.contextBearing.textContent = `${relative < 0 ? 'L' : 'R'} ${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`;
if (relative === null) if (relative === null) this.contextBearing.textContent = t('BRG —');
this.contextBearing.textContent = t('cockpit.context.bearingNone');
else if (Math.abs(relative) < 8) else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('cockpit.context.bearingAhead'); this.contextBearing.textContent = t('AHEAD');
else { else {
this.contextBearing.textContent = t('cockpit.context.bearingSide', { this.contextBearing.textContent = t('{side} {angle}', {
side: side: relative < 0 ? t('L') : t('R'),
relative < 0
? t('cockpit.context.sideLeft')
: t('cockpit.context.sideRight'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
}); });
} }
} }
if (this.contextUncertainty) { if (this.contextUncertainty) {
this.contextUncertainty.textContent = unknownCount this.contextUncertainty.textContent = unknownCount
? t('cockpit.context.uncertaintyInputsUnknown', { count: unknownCount }) ? tn(
: t('cockpit.context.uncertaintyInputsCurrent'); 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) { if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt) 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); const heading = normalizeHeading(this.heading ?? info.track ?? 0);
if (this.callsign) { if (this.callsign) {
this.callsign.textContent = this.callsign.textContent =
info.callsign || info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
info.registration ||
info.icao24 ||
t('cockpit.hud.fallbackCallsign');
} }
const speedKt = Number.isFinite(info.velocityMps) const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384 ? info.velocityMps * 1.94384
@@ -133,19 +130,20 @@ export function updateHud(
// State-sibling keys at the writer: each feed state translates // State-sibling keys at the writer: each feed state translates
// independently so no state can leak English under other locales. // independently so no state can leak English under other locales.
const feedState = this.surfaceAcquiring const feedState = this.surfaceAcquiring
? t('cockpit.hud.metaFeedAcquiringSurface') ? t('ACQUIRING SURFACE')
: this.surfaceFallback : this.surfaceFallback
? t('cockpit.hud.metaFeedSurfaceFallback') ? t('SURFACE FALLBACK')
: info.stale : info.stale
? t('cockpit.hud.metaFeedStale') ? t('STALE FEED')
: t('cockpit.hud.metaFeedLive'); : t('LIVE TRACK');
this.aircraftMeta.textContent = t('cockpit.hud.aircraftMetaTemplate', { this.aircraftMeta.textContent = t(
aircraftClass: '{aircraftClass} · {feedState} · COURSE ALIGNED',
info.layerId === 'military' {
? t('cockpit.hud.metaClassMilitary') aircraftClass:
: t('cockpit.hud.metaClassCommercial'), info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'),
feedState, feedState,
}); },
);
} }
this.updateRoute(info); this.updateRoute(info);
if ( if (
@@ -170,14 +168,13 @@ export function updateRoute(info) {
const validDestination = const validDestination =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const routeLabel = (airport) => const routeLabel = (airport) =>
[airport?.code, airport?.name].filter(Boolean).join(' · ') || [airport?.code, airport?.name].filter(Boolean).join(' · ') || t('UNKNOWN');
t('cockpit.route.unknownEndpoint');
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin); if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination); if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) { if (this.routeStatus) {
this.routeStatus.textContent = validDestination this.routeStatus.textContent = validDestination
? t('cockpit.route.statusArrowEstimated') ? t('ARROW · ESTIMATED DIRECTION')
: t('cockpit.route.statusUnavailable'); : t('ROUTE DATA UNAVAILABLE');
} }
if (this.route) this.route.hidden = !origin && !destination; if (this.route) this.route.hidden = !origin && !destination;
if ( if (
@@ -211,7 +208,7 @@ export function updateRoute(info) {
); );
} }
if (this.routeDirectionLabel) { if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = t('cockpit.route.directionLabel', { this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`, bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
}); });
} }
@@ -222,14 +219,12 @@ export function syncWeatherToggle(enabled) {
const active = !!enabled; const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active)); this.weatherToggle.setAttribute('aria-pressed', String(active));
const weatherLabel = active const weatherLabel = active
? t('cockpit.context.weatherDisableAriaLabel') ? t('Disable cockpit weather effects')
: t('cockpit.context.weatherEnableAriaLabel'); : t('Enable cockpit weather effects');
this.weatherToggle.setAttribute('aria-label', weatherLabel); this.weatherToggle.setAttribute('aria-label', weatherLabel);
this.weatherToggle.title = weatherLabel; this.weatherToggle.title = weatherLabel;
if (this.weatherState) { if (this.weatherState) {
this.weatherState.textContent = active this.weatherState.textContent = active ? t('ON') : t('OFF');
? t('cockpit.context.weatherStateOn')
: t('cockpit.context.weatherStateOff');
} }
} }
@@ -248,19 +243,21 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
const names = { const names = {
optical: 'Normal', optical: 'Normal',
crt: 'CRT', crt: 'CRT',
nvg: t('cockpit.vision.styleNameNightVision'), nvg: t('Night vision'),
thermal: t('cockpit.vision.styleNameThermal'), thermal: t('Thermal'),
anime: t('cockpit.vision.styleNameAnime'), anime: t('Anime'),
noir: t('cockpit.vision.styleNameNoir'), noir: t('Noir'),
snow: t('cockpit.vision.styleNameSnow'), snow: t('Snow'),
}; };
if (this.visionCurrent) { if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next; this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute( this.visionCurrent.setAttribute(
'aria-label', '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], style: names[next],
}); });
} }
+6 -12
View File
@@ -1,5 +1,5 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */ /** Measure Cockpit panels against visible HUD and viewport obstacles. */
import { t } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
import { import {
resolveCockpitUtilityAnchor, resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout, resolveCockpitUtilityLayout,
@@ -45,16 +45,10 @@ export function setContextCollapsed(collapsed) {
); );
this.contextToggle.setAttribute( this.contextToggle.setAttribute(
'aria-label', 'aria-label',
t( t(expanded ? msg('Expand Contact panel') : msg('Collapse Contact panel')),
expanded
? 'cockpit.context.toggleExpandAriaLabel'
: 'cockpit.context.toggleAriaLabel',
),
); );
this.contextToggle.title = t( this.contextToggle.title = t(
expanded expanded ? msg('Expand contact panel') : msg('Collapse contact panel'),
? 'cockpit.context.toggleExpandTitle'
: 'cockpit.context.toggleTitle',
); );
const icon = this.contextToggle.querySelector('.material-symbols-outlined'); const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right'; if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
@@ -78,12 +72,12 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
'aria-label', 'aria-label',
t( t(
expanded expanded
? 'cockpit.brief.collapseAriaLabel' ? msg('Collapse cockpit briefing panel')
: 'cockpit.brief.expandAriaLabel', : msg('Expand cockpit briefing panel'),
), ),
); );
this.signalToggle.title = t( 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'); const icon = this.signalToggle.querySelector('.material-symbols-outlined');
if (icon) if (icon)
+13 -13
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */ /** 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; 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 = [ export const COCKPIT_BRIEF_PAGES = [
{ {
id: 'signals', id: 'signals',
kickerKey: 'cockpit.brief.kicker', kickerKey: msg('LIVE SIGNALS'),
subtitleKey: 'cockpit.brief.subtitle', subtitleKey: msg('OBSERVED / MAPPED PINGS'),
sourceKey: 'cockpit.brief.sourceNote', sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
}, },
{ {
id: 'news', id: 'news',
kickerKey: 'cockpit.brief.kickerNews', kickerKey: msg('REGIONAL NEWS'),
subtitleKey: 'cockpit.brief.subtitleNews', subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
// RSS provider attribution is a keep-English boundary string. // RSS provider attribution is a keep-English boundary string.
source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT', source: 'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
}, },
{ {
id: 'local', id: 'local',
kickerKey: 'cockpit.brief.kickerLocal', kickerKey: msg('LOCAL INFO'),
subtitleKey: 'cockpit.brief.subtitleLocal', subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
// Data-source attribution is a keep-English boundary string. // Data-source attribution is a keep-English boundary string.
source: 'NATURAL EARTH · OPEN-METEO · UTC', source: 'NATURAL EARTH · OPEN-METEO · UTC',
}, },
@@ -84,17 +84,17 @@ export function isRenderedOnScreen(element) {
export function formatCockpitBriefAge(value) { export function formatCockpitBriefAge(value) {
const timestamp = Date.parse(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)); 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); const hours = Math.round(minutes / 60);
return hours < 48 return hours < 48
? t('cockpit.brief.age.hours', { count: hours }) ? t('{count}H AGO', { count: hours })
: t('cockpit.brief.age.days', { count: Math.round(hours / 24) }); : t('{count}D AGO', { count: Math.round(hours / 24) });
} }
export function formatCockpitWindDirection(value) { 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 labels = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const normalized = ((value % 360) + 360) % 360; const normalized = ((value % 360) + 360) % 360;
return `${labels[Math.round(normalized / 45) % labels.length]} · ${Math.round(normalized)}°`; 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. */ /** 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() { export function renderCockpitSignals() {
if (!this.signalList) return; if (!this.signalList) return;
@@ -58,7 +58,7 @@ export function renderCockpitSignals() {
if (item.target) { if (item.target) {
heading.dataset.signalLayer = item.target.layerId; heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id; 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) if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label); heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title); setText(heading.children[0], item.title);
@@ -127,11 +127,11 @@ export function updateCockpitSignals(snapshot, unknownCount) {
key: `flight:${subject.layerId}:${subject.id}`, key: `flight:${subject.layerId}:${subject.id}`,
tone: 'track', tone: 'track',
title: subject.label || subject.id, title: subject.label || subject.id,
detail: t('cockpit.signal.contactCurrent', { detail: t('{aircraftClass} · CURRENT', {
aircraftClass: aircraftClass:
subject.layerId === 'military' subject.layerId === 'military'
? t('cockpit.signal.classMilitary') ? t('MILITARY FLIGHT')
: t('cockpit.signal.classCommercial'), : t('COMMERCIAL FLIGHT'),
}), }),
target: { layerId: subject.layerId, id: String(subject.id) }, target: { layerId: subject.layerId, id: String(subject.id) },
distanceM: -1, distanceM: -1,
@@ -151,14 +151,14 @@ export function updateCockpitSignals(snapshot, unknownCount) {
// contact reads as its registration here too. Same helper the // contact reads as its registration here too. Same helper the
// Context panel's nearest list uses. // Context panel's nearest list uses.
title: this.services.formatAwarenessLabel(item), title: this.services.formatAwarenessLabel(item),
detail: t('cockpit.signal.contactRange', { detail: t('{aircraftClass} · {distance}', {
aircraftClass: aircraftClass:
cohort.id === 'military' cohort.id === 'military'
? t('cockpit.signal.classMilitary') ? t('MILITARY FLIGHT')
: t('cockpit.signal.classCommercial'), : t('COMMERCIAL FLIGHT'),
distance: Number.isFinite(item.distanceM) distance: Number.isFinite(item.distanceM)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM` ? `${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) }, target: { layerId: cohort.id, id: String(id) },
distanceM: item.distanceM ?? Infinity, distanceM: item.distanceM ?? Infinity,
@@ -179,8 +179,12 @@ export function updateCockpitSignals(snapshot, unknownCount) {
nextItems.splice(4, Math.max(0, nextItems.length - 4), { nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status', key: 'input-status',
tone: 'warning', tone: 'warning',
title: t('cockpit.signal.inputsUnknown', { count: unknownCount }), title: tn(
detail: sources || t('cockpit.signal.sourceStatusUnavailable'), unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null, target: null,
timestamp: timestamp:
previous.get('input-status')?.timestamp || previous.get('input-status')?.timestamp ||
+6 -9
View File
@@ -59,8 +59,8 @@ export function syncTr3bToggle(info) {
this.tr3bToggle.hidden = !icao24; this.tr3bToggle.hidden = !icao24;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false'); this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
this.tr3bToggle.title = converted this.tr3bToggle.title = converted
? t('cockpit.context.tr3bRestoreTitle') ? t('Restore real aircraft')
: t('cockpit.context.tr3bTitle'); : t('Reclassify as TR-3B');
} }
export function syncEntry() { export function syncEntry() {
@@ -184,20 +184,17 @@ export function enter() {
this.showBriefPage(0); this.showBriefPage(0);
this.startBriefRotation(); this.startBriefRotation();
const trackLabel = const trackLabel =
info.callsign || info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
info.registration ||
info.icao24 ||
t('cockpit.hud.fallbackCallsign');
const trackHeading = String( const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)), Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0'); ).padStart(3, '0');
this.pushCockpitSignal( this.pushCockpitSignal(
'track', 'track',
'track', 'track',
t('cockpit.signal.trackAcquired'), t('TRACK ACQUIRED'),
t('cockpit.signal.trackDetail', { t('{label} · COURSE {heading}°', {
label: trackLabel, label: trackLabel,
heading: `${trackHeading}°`, heading: trackHeading,
}), }),
); );
this.updateHud(info, performance.now(), true); 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 { import {
settleUserFacingContextAction, settleUserFacingContextAction,
contextModeWord, contextModeWord,
@@ -6,7 +6,7 @@ import {
export async function _runUserFacingContextAction( export async function _runUserFacingContextAction(
operation, operation,
message = t('cockpit.context.toastRestoreFailed'), message = t('Context could not restore every layer; try again'),
{ falseIsFailure = true, localizedMessage = null } = {}, { falseIsFailure = true, localizedMessage = null } = {},
) { ) {
if (this.destroyed) return false; if (this.destroyed) return false;
@@ -223,25 +223,29 @@ export function clearSelectedLayers() {
const operation = managerOperation const operation = managerOperation
.then((result) => { .then((result) => {
if (result.targetIds.length === 0) { 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) { } else if (result.notClearedIds.length > 0) {
this.showToast( this.showToast(
t('layers.clear.toast.notCleared', { tn(
count: result.notClearedIds.length, result.notClearedIds.length,
}), '{count} data layer could not be cleared',
'{count} data layers could not be cleared',
),
); );
} else { } else {
this.showToast( this.showToast(
t('layers.clear.toast.cleared', { tn(
count: result.clearedIds.length, result.clearedIds.length,
}), 'Cleared {count} data layer',
'Cleared {count} data layers',
),
); );
} }
return result; return result;
}) })
.catch((error) => { .catch((error) => {
console.warn('[Data] clear selected layers failed', 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 { return {
targetIds: [], targetIds: [],
items: [], items: [],
+13 -9
View File
@@ -33,7 +33,7 @@ export function _initGlobalContextPanel() {
void this._runUserFacingContextAction( void this._runUserFacingContextAction(
(notificationToken) => (notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }), this._selectContextMode(nextMode, { notificationToken }),
t('cockpit.context.toastTransitionFailedContacts'), t('Contacts could not complete the requested transition; try again'),
).then((succeeded) => { ).then((succeeded) => {
if ( if (
!this.destroyed && !this.destroyed &&
@@ -62,7 +62,9 @@ export function _initGlobalContextPanel() {
void this._runUserFacingContextAction( void this._runUserFacingContextAction(
(notificationToken) => (notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }), this._selectContextMode(nextMode, { notificationToken }),
t('cockpit.context.toastTransitionFailedMissions'), t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => { ).then((succeeded) => {
if ( if (
!this.destroyed && !this.destroyed &&
@@ -109,14 +111,16 @@ export function _initGlobalContextPanel() {
this.showToast( this.showToast(
stats?.statusMessage || stats?.statusMessage ||
(stats?.status === 'zoom-in' (stats?.status === 'zoom-in'
? t('cockpit.context.toastZoomToSearch') ? t('Zoom in to search mapped installations')
: t('cockpit.context.toastInstallationsRefreshed')), : t('Nearby installations refreshed')),
); );
return true; return true;
}, t('cockpit.context.toastInstallationsRefreshFailed')).finally(() => { }, t('Nearby installations could not be refreshed; try again')).finally(
if (this.destroyed) return; () => {
button.setAttribute('aria-disabled', 'false'); if (this.destroyed) return;
button.setAttribute('aria-busy', 'false'); button.setAttribute('aria-disabled', 'false');
}); button.setAttribute('aria-busy', 'false');
},
);
}); });
} }
+5 -6
View File
@@ -71,7 +71,7 @@ export function _handleContextLayerChange(change) {
}, },
{ {
localizedMessage: t( 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._userFacingContextNotificationTokens.has(change.notificationToken)
) { ) {
this.showToast( this.showToast(
change.reason || t('cockpit.context.toastLayerUnavailable'), change.reason ||
t('That layer is unavailable in the current Context mode'),
); );
} }
this._syncContextModeButtons(); this._syncContextModeButtons();
return; return;
} }
if (change?.type === 'visibility-failed') { if (change?.type === 'visibility-failed') {
const failureMessage = t('cockpit.context.toastLayerLifecycleFailed', { const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId, layerId: change.layerId,
action: change.enabled action: change.enabled ? t('start') : t('stop'),
? t('cockpit.context.actionStart')
: t('cockpit.context.actionStop'),
}); });
// A failed direct Context-shell START has already had its siblings // A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager // cleared by the visibility guard. Wait outside the synchronous manager
+9 -6
View File
@@ -125,12 +125,15 @@ export function connectContextManager(manager) {
restoreError, restoreError,
); );
} }
return t('cockpit.context.toastStartBlocked', { return t(
mode: '{mode} could not start because another layer did not stop cleanly',
entryMode === 'space-missions' {
? t('cockpit.context.modeSpaceMissions') mode:
: t('cockpit.context.modeContext'), entryMode === 'space-missions'
}); ? t('Space Missions')
: t('Context'),
},
);
} finally { } finally {
if (ownsNotificationToken) { if (ownsNotificationToken) {
this._userFacingContextNotificationTokens.delete( this._userFacingContextNotificationTokens.delete(
+54 -56
View File
@@ -5,49 +5,45 @@ export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { createWeatherPanel } from './weatherPanel.js'; 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 // Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA
// hooks, CSS feed-* classes); only the final label value translates, at this // hooks, CSS feed-* classes); only the final label value translates, at this
// presentation boundary. See docs/TRANSLATORS.md keep-English boundary. // presentation boundary. See docs/TRANSLATORS.md keep-English boundary.
const FEED_STATE_LABEL_KEYS = Object.freeze({ const FEED_STATE_LABEL_KEYS = Object.freeze({
nominal: 'layers.status.on', nominal: msg('ON'),
loading: 'layers.status.loading', loading: msg('LOADING'),
degraded: 'layers.status.degraded', degraded: msg('DEGRADED'),
stale: 'layers.status.stale', stale: msg('STALE'),
partial: 'layers.status.partial', partial: msg('PARTIAL'),
fallback: 'layers.status.fallback', fallback: msg('FALLBACK'),
unavailable: 'layers.status.unavailable', unavailable: msg('UNAVAILABLE'),
}); });
// Layer display names, localized at the same presentation boundary. Registry // Layer display names, localized at the same presentation boundary. Registry
// ids stay English; a layer without an entry here renders its module name, // ids stay English; a layer without an entry here renders its module name,
// falling back to the English panel override when one exists. // falling back to the English panel override when one exists.
const LAYER_NAME_KEYS = Object.freeze({ const LAYER_NAME_KEYS = Object.freeze({
flights: 'layers.name.liveFlights', flights: msg('Live Flights'),
military: 'layers.name.militaryFlights', military: msg('Military Flights'),
earthquakes: 'layers.name.earthquakes', earthquakes: msg('Earthquakes (24h)'),
satellites: 'layers.name.satellites', satellites: msg('Satellites'),
'rocket-launches': 'layers.name.rocketLaunches', 'rocket-launches': msg('Space Missions (30d)'),
traffic: 'layers.name.traffic', traffic: msg('Street Traffic'),
cctv: 'layers.name.cctv', cctv: msg('CCTV'),
radio: 'layers.name.radio', radio: msg('Radio'),
bikeshare: 'layers.name.bikeshare', bikeshare: msg('Bikeshare'),
'ais-live-vessels': 'layers.name.aisVessels', 'ais-live-vessels': msg('Live AIS Vessels'),
'military-installations': 'layers.name.installations', 'military-installations': msg('Mapped Installations'),
'military-awareness': 'layers.name.globalContext', 'military-awareness': msg('Global Context'),
'local-datacenters': 'layers.name.datacenters', 'local-datacenters': msg('Datacenters'),
'local-dams': 'layers.name.dams', 'local-dams': msg('Dams'),
'telegeography-submarine-cables': 'layers.name.submarineCables', 'telegeography-submarine-cables': msg('Submarine Cables'),
'local-firms': 'layers.name.fires', 'local-firms': msg('FIRMS Active Fires'),
}); });
function lifecycleStateLabel(lifecycleState) { function lifecycleStateLabel(lifecycleState) {
return t( return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING'));
lifecycleState === 'enabling'
? 'layers.status.enabling'
: 'layers.status.disabling',
);
} }
const FEED_STATE_LABELS = Object.freeze({ const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON', nominal: 'ON',
@@ -149,7 +145,9 @@ export function layerKeyRequirementTooltip(layer = {}) {
const envVars = requiresKeyId const envVars = requiresKeyId
? keySetupRequirementEnvVars(requiresKeyId) ? keySetupRequirementEnvVars(requiresKeyId)
: null; : 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. */ /** Layer row presentation over supplied state and actions; no layer imports. */
@@ -575,26 +573,29 @@ export class LayerPanel {
const lifecycleState = const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') { if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return t('layers.meta.transitioning', { return t('{state} · {source}', {
state: lifecycleStateLabel(lifecycleState), state: lifecycleStateLabel(lifecycleState),
source, source,
}); });
} }
if (layer.lifecycleUncertain) { if (layer.lifecycleUncertain) {
return t('layers.meta.uncertainLifecycle', { source }); return t(
'UNCERTAIN · {source} · lifecycle state requires reconciliation',
{ source },
);
} }
const presentedError = const presentedError =
stats.error || stats.lastError || stats.managerRefreshError; stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) { if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('layers.meta.stateSourceRetry', { return t('{state} · {source} · {detail} · retry {seconds}s', {
state: stateLabel, state: stateLabel,
source, source,
detail: presentedError, detail: presentedError,
seconds: stats.retryInSec, seconds: stats.retryInSec,
}); });
} }
return t('layers.meta.stateSourceDetail', { return t('{state} · {source} · {detail}', {
state: stateLabel, state: stateLabel,
source, source,
detail: presentedError, detail: presentedError,
@@ -609,22 +610,20 @@ export class LayerPanel {
) { ) {
return `${source} · ${stats.statusMessage.trim()}`; return `${source} · ${stats.statusMessage.trim()}`;
} }
const ago = stats.lastUpdate const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
? this._timeAgo(stats.lastUpdate)
: t('layers.meta.never');
if (stats.loading) { if (stats.loading) {
const loadingLabel = const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim() ? stats.loadingLabel.trim()
: t('layers.meta.loading'); : t('loading...');
return t('layers.meta.sourceLoading', { source, loadingLabel }); return t('{source} · {loadingLabel}', { source, loadingLabel });
} }
if (feedState === 'fallback') { if (feedState === 'fallback') {
const detail = const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim() ? stats.loadingLabel.trim()
: stats.coverage || ago; : stats.coverage || ago;
return t('layers.meta.stateSourceDetail', { return t('{state} · {source} · {detail}', {
state: stateLabel, state: stateLabel,
source, source,
detail, detail,
@@ -637,12 +636,12 @@ export class LayerPanel {
Number.isInteger(rawRowCount) && Number.isInteger(rawRowCount) &&
acceptedRowCount >= 0 && acceptedRowCount >= 0 &&
rawRowCount > acceptedRowCount rawRowCount > acceptedRowCount
? t('layers.meta.partialCounts', { ? t('{accepted} of {raw} records accepted', {
accepted: acceptedRowCount, accepted: acceptedRowCount,
raw: rawRowCount, raw: rawRowCount,
}) })
: t('layers.meta.partialIncomplete'); : t('incomplete snapshot');
return t('layers.meta.partialDetail', { return t('{state} · {source} · {detail} · {ago}', {
state: stateLabel, state: stateLabel,
source, source,
detail, detail,
@@ -651,26 +650,26 @@ export class LayerPanel {
} }
if (feedState === 'stale') { if (feedState === 'stale') {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return t('layers.meta.stateSourceAgoRetry', { return t('{state} · {source} · {ago} · retrying in {seconds}s', {
state: stateLabel, state: stateLabel,
source, source,
ago, ago,
seconds: stats.retryInSec, seconds: stats.retryInSec,
}); });
} }
return t('layers.meta.stateSourceAgo', { return t('{state} · {source} · {ago}', {
state: stateLabel, state: stateLabel,
source, source,
ago, ago,
}); });
} }
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return t('layers.meta.sourceLoading', { return t('{source} · {loadingLabel}', {
source, source,
loadingLabel: stats.loadingLabel.trim(), loadingLabel: stats.loadingLabel.trim(),
}); });
} }
return t('layers.meta.sourceAgo', { source, ago }); return t('{source} · {ago}', { source, ago });
} }
_syncToggleButton(button, layer) { _syncToggleButton(button, layer) {
@@ -712,18 +711,18 @@ export class LayerPanel {
button.textContent = transitioning button.textContent = transitioning
? lifecycleStateLabel(layer.lifecycleState) ? lifecycleStateLabel(layer.lifecycleState)
: uncertain : uncertain
? t('layers.status.uncertain') ? t('UNCERTAIN')
: layer.enabled || unavailable : layer.enabled || unavailable
? t(FEED_STATE_LABEL_KEYS[feedState]) ? t(FEED_STATE_LABEL_KEYS[feedState])
: t('layers.status.off'); : t('OFF');
const keyGuidance = unavailable const keyGuidance = unavailable
? String(layer.stats.error || t('layers.meta.dataSourceUnavailable')) ? String(layer.stats.error || t('Data source unavailable'))
: layerKeyRequirementTooltip(layer); : layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED // 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 // without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one. // when the layer needs no key, or already has one.
button.title = keyGuidance; button.title = keyGuidance;
const ariaLabel = t('layers.meta.toggleAriaLabel', { const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer), name: panelLabel(layer),
state: button.textContent, state: button.textContent,
}); });
@@ -740,10 +739,9 @@ export class LayerPanel {
_timeAgo(timestamp) { _timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000); const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return t('layers.meta.justNow'); if (diff < 5) return t('just now');
if (diff < 60) return t('layers.meta.secondsAgo', { count: diff }); if (diff < 60) return t('{count}s ago', { count: diff });
if (diff < 3600) if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
return t('layers.meta.minutesAgo', { count: Math.floor(diff / 60) }); return t('{count}h ago', { count: Math.floor(diff / 3600) });
return t('layers.meta.hoursAgo', { 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). // localized static readout (resolved place names stay verbatim data).
this.elements.statusCity.textContent = this.elements.statusCity.textContent =
lines.city === LOCATION_MINI_EMPTY.city lines.city === LOCATION_MINI_EMPTY.city
? t('cockpit.location.miniCityInitial') ? t('📍 Location: --')
: lines.city; : lines.city;
if (lines.poi === LOCATION_MINI_EMPTY.poi) { if (lines.poi === LOCATION_MINI_EMPTY.poi) {
this.elements.statusPoi.textContent = t( this.elements.statusPoi.textContent = t('Landmark: --');
'cockpit.location.miniPoiInitial',
);
} else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) { } else if (lines.poi === LOCATION_MINI_SEARCHED_PLACEHOLDER.poi) {
this.elements.statusPoi.textContent = t( this.elements.statusPoi.textContent = t('Searched location');
'cockpit.location.miniSearchedPlaceholder',
);
} else { } else {
this.elements.statusPoi.textContent = lines.poi; this.elements.statusPoi.textContent = lines.poi;
} }
@@ -173,7 +169,7 @@ export class LocationControls {
const icon = this.doc.createElement('span'); const icon = this.doc.createElement('span');
icon.className = 'orbit-icon'; icon.className = 'orbit-icon';
icon.textContent = '↻'; icon.textContent = '↻';
this.orbitIndicator.append(icon, ` ${t('cockpit.location.orbitLabel')}`); this.orbitIndicator.append(icon, ` ${t('ORBIT')}`);
this.doc.body.appendChild(this.orbitIndicator); this.doc.body.appendChild(this.orbitIndicator);
} }
return this.orbitIndicator; return this.orbitIndicator;
+7 -8
View File
@@ -89,9 +89,8 @@ export class LocationNavigation {
this._collapsePOIRow(); this._collapsePOIRow();
this._updateLocationMiniStatus(); this._updateLocationMiniStatus();
} else if (change.type === 'missing') } else if (change.type === 'missing')
this._showToast(t('cockpit.location.toastNotFound')); this._showToast(t('Location not found'));
else if (change.type === 'failed') else if (change.type === 'failed') this._showToast(t('Search failed'));
this._showToast(t('cockpit.location.toastSearchFailed'));
else if (change.type === 'settled') else if (change.type === 'settled')
this._settleLocationSearchUi(change.generation); this._settleLocationSearchUi(change.generation);
else if ( else if (
@@ -280,7 +279,7 @@ export class LocationNavigation {
_toggleOrbit() { _toggleOrbit() {
if (!this._currentTarget) { if (!this._currentTarget) {
this._showToast(t('cockpit.location.toastFlyToPoiFirst')); this._showToast(t('Fly to a POI first'));
return; return;
} }
@@ -382,11 +381,11 @@ export class LocationNavigation {
}; };
this._resetGlobeBtn?.setAttribute( this._resetGlobeBtn?.setAttribute(
'aria-label', 'aria-label',
t('cockpit.hud.resetGlobeAria'), t('Reset to full globe view'),
); );
this._cockpitResetGlobeBtn?.setAttribute( this._cockpitResetGlobeBtn?.setAttribute(
'aria-label', 'aria-label',
t('cockpit.hud.resetGlobeCockpitAria'), t('Reset cockpit to full globe view'),
); );
this._globeResetPromise = null; this._globeResetPromise = null;
this._cancelGlobeReset = null; this._cancelGlobeReset = null;
@@ -402,11 +401,11 @@ export class LocationNavigation {
}, 4200); }, 4200);
this._resetGlobeBtn?.setAttribute( this._resetGlobeBtn?.setAttribute(
'aria-label', 'aria-label',
t('cockpit.hud.resettingGlobeAria'), t('Resetting to full globe view'),
); );
this._cockpitResetGlobeBtn?.setAttribute( this._cockpitResetGlobeBtn?.setAttribute(
'aria-label', 'aria-label',
t('cockpit.hud.resettingGlobeCockpitAria'), t('Resetting cockpit to full globe view'),
); );
const target = flyToGlobeView(this.viewer, { const target = flyToGlobeView(this.viewer, {
onComplete: () => finish(false), onComplete: () => finish(false),
+1 -3
View File
@@ -29,9 +29,7 @@ export function createMapSourceControls({
statusElement.textContent = statusElement.textContent =
state.status === 'switching' state.status === 'switching'
? '...' ? '...'
: stack?.shortLabel || : stack?.shortLabel || stack?.label || t('MAP');
stack?.label ||
t('cockpit.presets.mapStackFallback');
statusElement.classList.toggle('warn', !!state.lastError); statusElement.classList.toggle('warn', !!state.lastError);
} }
} }
+10 -12
View File
@@ -1,5 +1,5 @@
import { UiLifetime } from './uiLifetime.js'; import { UiLifetime } from './uiLifetime.js';
import { t } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
import { displayPanelScroller } from './displayPanelScroll.js'; import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js'; import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js'; import { PanelLayoutController } from './panelLayoutController.js';
@@ -311,26 +311,24 @@ export class PanelChrome {
const panelName = const panelName =
panelEl panelEl
.querySelector('.panel-title, .pp-header-label') .querySelector('.panel-title, .pp-header-label')
?.textContent?.trim() || t('cockpit.panel.fallbackName'); ?.textContent?.trim() || t('panel');
const titleKey = collapsed const titleKey = collapsed
? 'cockpit.panel.expandTitle' ? msg('Expand {name}')
: 'cockpit.panel.collapseTitle'; : msg('Collapse {name}');
btn.title = t(titleKey, { name: panelName }); btn.title = t(titleKey, { name: panelName });
btn.setAttribute('aria-label', t(titleKey, { name: panelName })); btn.setAttribute('aria-label', t(titleKey, { name: panelName }));
if (panelEl.id === 'radio-panel') { if (panelEl.id === 'radio-panel') {
// Expand state reuses the layers.* keys seeded on the static button; // Expand state reuses the layers.* keys seeded on the static button;
// the collapse state is runtime-only, so it owns cockpit.* keys. // the collapse state is runtime-only, so it owns cockpit.* keys.
btn.title = t( btn.title = t(
collapsed collapsed ? msg('Expand Radio') : msg('Collapse Radio'),
? 'layers.radio.expandTitle'
: 'cockpit.panel.radioCollapseTitle',
); );
btn.setAttribute( btn.setAttribute(
'aria-label', 'aria-label',
t( t(
collapsed collapsed
? 'layers.radio.expandAriaLabel' ? msg('Expand Radio section')
: 'cockpit.panel.radioCollapseAria', : msg('Collapse Radio section'),
), ),
); );
} }
@@ -342,10 +340,10 @@ export class PanelChrome {
const panelName = const panelName =
panelEl panelEl
.querySelector('.panel-title, .location-toolbar-label') .querySelector('.panel-title, .location-toolbar-label')
?.textContent?.trim() || t('cockpit.panel.fallbackName'); ?.textContent?.trim() || t('panel');
const titleKey = collapsed const titleKey = collapsed
? 'cockpit.panel.expandTitle' ? msg('Expand {name}')
: 'cockpit.panel.collapseTitle'; : msg('Collapse {name}');
dockToggle.setAttribute('aria-expanded', String(!collapsed)); dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName })); dockToggle.setAttribute('aria-label', t(titleKey, { name: panelName }));
dockToggle.title = 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.'), key.startsWith('godsEyeView.v6.panelPos.'),
); );
if (hadOldPositions) { if (hadOldPositions) {
this._showToast(t('cockpit.panel.layoutResetToast')); this._showToast(
t('Panel layout updated — positions reset to new defaults'),
);
} }
} catch { } catch {
// storage unavailable // storage unavailable
+17 -21
View File
@@ -1,4 +1,4 @@
import { t } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
import { import {
radioTunerSlot, radioTunerSlot,
radioTunerCommitSlot, radioTunerCommitSlot,
@@ -53,14 +53,14 @@ export function bindRadioControls() {
'aria-label', 'aria-label',
t( t(
displayOpen displayOpen
? 'cockpit.utility.displayToggleCollapseAriaLabel' ? msg('Collapse Cockpit display options')
: 'cockpit.utility.displayToggleAriaLabel', : msg('Expand Cockpit display options'),
), ),
); );
this._cockpitDisplayToggleBtn.title = t( this._cockpitDisplayToggleBtn.title = t(
displayOpen displayOpen
? 'cockpit.utility.displayToggleCollapseAriaLabel' ? msg('Collapse Cockpit display options')
: 'cockpit.utility.displayToggleAriaLabel', : msg('Expand Cockpit display options'),
); );
} }
if (this._cockpitRadioToggleBtn) { if (this._cockpitRadioToggleBtn) {
@@ -69,14 +69,14 @@ export function bindRadioControls() {
'aria-label', 'aria-label',
t( t(
radioOpen radioOpen
? 'cockpit.utility.radioToggleCollapseAriaLabel' ? msg('Collapse Cockpit Radio controls')
: 'cockpit.utility.radioToggleAriaLabel', : msg('Expand Cockpit Radio controls'),
), ),
); );
this._cockpitRadioToggleBtn.title = t( this._cockpitRadioToggleBtn.title = t(
radioOpen radioOpen
? 'cockpit.utility.radioToggleCollapseAriaLabel' ? msg('Collapse Cockpit Radio controls')
: 'cockpit.utility.radioToggleAriaLabel', : msg('Expand Cockpit Radio controls'),
); );
} }
if (!expanded && returnFocus) { if (!expanded && returnFocus) {
@@ -155,21 +155,21 @@ export function bindRadioControls() {
if (this._radioTunerValue) { if (this._radioTunerValue) {
this._radioTunerValue.textContent = station this._radioTunerValue.textContent = station
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}` ? `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) if (this._radioTunerStation)
this._radioTunerStation.textContent = this._radioTunerStation.textContent =
station?.name || t('layers.radio.noStationAvailable'); station?.name || t('NO STATION AVAILABLE');
if (this._radioTunerSlider) { if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute( this._radioTunerSlider.setAttribute(
'aria-valuetext', 'aria-valuetext',
station station
? t('cockpit.radio.tunerStationAria', { ? t('{name}, station {index} of {total}', {
name: station.name, name: station.name,
index: slot.stationIndex + 1, index: slot.stationIndex + 1,
total: this._radioTunerStations.length, total: this._radioTunerStations.length,
}) })
: t('cockpit.radio.tunerNoStationAria'), : t('No station available'),
); );
} }
if (syncStatic) if (syncStatic)
@@ -320,11 +320,9 @@ export function bindRadioControls() {
this._radioTunerBandPinnedForNavigation = false; this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') { if (result.reason === 'station-unavailable') {
if (this._radioTunerValue) if (this._radioTunerValue)
this._radioTunerValue.textContent = t('cockpit.radio.tunerOffAir'); this._radioTunerValue.textContent = t('OFF AIR');
if (this._radioTunerStation) if (this._radioTunerStation)
this._radioTunerStation.textContent = t( this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
'cockpit.radio.tunerStationUnavailable',
);
this._radioTunerSlider?.setAttribute( this._radioTunerSlider?.setAttribute(
'aria-valuetext', 'aria-valuetext',
'Station unavailable after directory refresh', 'Station unavailable after directory refresh',
@@ -367,10 +365,8 @@ export function bindRadioControls() {
// (identical value under 'en'). // (identical value under 'en').
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`, `Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
{ {
localizedMessage: t('cockpit.radio.toastLifecycleFailed', { localizedMessage: t('Radio could not {action} cleanly', {
action: enabling action: enabling ? t('start') : t('stop'),
? t('cockpit.context.actionStart')
: t('cockpit.context.actionStop'),
}), }),
}, },
); );
+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 { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js'; import { renderRadioState } from './radioPresentation.js';
@@ -201,8 +201,8 @@ export class RadioControls {
); );
const launcherLabel = t( const launcherLabel = t(
compactOpen compactOpen
? 'cockpit.context.radioToggleCloseAriaLabel' ? msg('Close compact Radio controls')
: 'cockpit.context.radioToggleAriaLabel', : msg('Open compact Radio controls'),
); );
this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel); this._contextRadioToggleBtn.setAttribute('aria-label', launcherLabel);
this._contextRadioToggleBtn.title = launcherLabel; this._contextRadioToggleBtn.title = launcherLabel;
+49 -58
View File
@@ -35,29 +35,27 @@ export function renderRadioState(state) {
if (this._radioLayerState) { if (this._radioLayerState) {
// Lifecycle enum values map to status-chip keys; counts stay machine. // Lifecycle enum values map to status-chip keys; counts stay machine.
const lifecycleLabel = const lifecycleLabel =
lifecycleState === 'enabling' lifecycleState === 'enabling' ? t('ENABLING') : t('DISABLING');
? t('layers.status.enabling')
: t('layers.status.disabling');
this._radioLayerState.textContent = transitioning this._radioLayerState.textContent = transitioning
? lifecycleLabel ? lifecycleLabel
: uncertain : uncertain
? t('layers.status.uncertain') ? t('UNCERTAIN')
: state.loading : state.loading
? t('cockpit.radio.stateSync') ? t('SYNC')
: enabled : enabled
? `${state.filteredCount}/${state.stationCount}` ? `${state.filteredCount}/${state.stationCount}`
: t('layers.status.off'); : t('OFF');
} }
const enableLabel = uncertain const enableLabel = uncertain
? t('layers.radio.reconcile') ? t('RECONCILE')
: enabled : enabled
? t('layers.radio.disable') ? t('DISABLE')
: t('layers.radio.enable'); : t('ENABLE');
const enableAria = uncertain const enableAria = uncertain
? t('layers.radio.reconcileAria') ? t('Reconcile Radio — lifecycle uncertain')
: enabled : enabled
? t('layers.radio.disableAria') ? t('Disable Radio')
: t('layers.radio.enableAria'); : t('Enable Radio');
if (this._radioEnableBtn) { if (this._radioEnableBtn) {
this._radioEnableBtn.classList.toggle('active', enabled); this._radioEnableBtn.classList.toggle('active', enabled);
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled)); this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
@@ -146,8 +144,8 @@ export function renderRadioState(state) {
); );
this._radioTunerBandLabel.textContent = this._radioTunerBandLabel.textContent =
state.filter === 'all' state.filter === 'all'
? t('layers.radio.bandLabel') ? t('DIRECTORY BAND')
: t('cockpit.radio.tunerCategoryBand', { : t('{category} BAND', {
category: String( category: String(
activeCategory?.label || state.filter, activeCategory?.label || state.filter,
).toUpperCase(), ).toUpperCase(),
@@ -170,7 +168,7 @@ export function renderRadioState(state) {
if (this._radioStationName) if (this._radioStationName)
this._radioStationName.textContent = this._radioStationName.textContent =
selected?.name || t('layers.radio.noStation'); selected?.name || t('NO STATION SELECTED');
if (this._radioStationMeta) { if (this._radioStationMeta) {
const place = selected const place = selected
? [selected.state, selected.countryCode].filter(Boolean).join(' · ') ? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
@@ -182,15 +180,15 @@ export function renderRadioState(state) {
: ''; : '';
this._radioStationMeta.textContent = selected this._radioStationMeta.textContent = selected
? [place, signal].filter(Boolean).join(' / ') || ? [place, signal].filter(Boolean).join(' / ') ||
t('layers.radio.meta.metadataOnly') t('Directory metadata only')
: state.loading : state.loading
? t('layers.radio.meta.loading') ? t('Loading station directory…')
: t('layers.radio.meta.chooseHint'); : t('Choose a globe marker or use next.');
} }
if (this._radioStationTags) { if (this._radioStationTags) {
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : []; const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
this._radioStationTags.textContent = tags.length this._radioStationTags.textContent = tags.length
? t('layers.radio.tags', { tags: tags.join(' · ') }) ? t('TAGS · {tags}', { tags: tags.join(' · ') })
: ''; : '';
} }
if (this._radioStationHomepage) { if (this._radioStationHomepage) {
@@ -213,14 +211,12 @@ export function renderRadioState(state) {
if (this._cockpitRadioNextBtn) if (this._cockpitRadioNextBtn)
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations; this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
const playbackAction = activePlayback const playbackAction = activePlayback
? t('cockpit.radio.actionPause') ? t('Pause')
: state.audioState === 'paused' : state.audioState === 'paused'
? t('cockpit.radio.actionResume') ? t('Resume')
: t('cockpit.radio.actionPlay'); : t('Play');
const playTargetWord = selected const playTargetWord = selected ? t('selected') : t('nearest');
? t('cockpit.radio.targetSelected') const playAria = t('{action} {target} radio station', {
: t('cockpit.radio.targetNearest');
const playAria = t('cockpit.radio.playStateAria', {
action: playbackAction, action: playbackAction,
target: playTargetWord, target: playTargetWord,
}); });
@@ -277,65 +273,60 @@ export function renderRadioState(state) {
} }
if (this._contextRadioMiniStation) { if (this._contextRadioMiniStation) {
this._contextRadioMiniStation.textContent = uncertain this._contextRadioMiniStation.textContent = uncertain
? t('cockpit.radio.miniStateUncertain') ? t('RADIO STATE UNCERTAIN')
: selected?.name || : selected?.name ||
(state.loading (state.loading ? t('SYNCING DIRECTORY') : t('RADIO READY'));
? t('cockpit.radio.miniSyncingDirectory')
: t('cockpit.context.radioMiniReady'));
} }
if (this._cockpitRadioStation) { if (this._cockpitRadioStation) {
this._cockpitRadioStation.textContent = uncertain this._cockpitRadioStation.textContent = uncertain
? t('layers.status.uncertain') ? t('UNCERTAIN')
: selected?.name || : selected?.name || (state.loading ? t('SYNCING') : t('READY'));
(state.loading
? t('cockpit.radio.stationSyncing')
: t('cockpit.radio.station.ready'));
} }
if (this._radioPlaybackState) { if (this._radioPlaybackState) {
const stationName = selected?.name || t('cockpit.radio.stationFallback'); const stationName = selected?.name || t('station');
const catalogSuffix = state.degraded const catalogSuffix = state.degraded
? state.stale ? state.stale
? t('layers.radio.state.staleDegradedDirectory') ? t(' · stale/degraded directory')
: t('layers.radio.state.degradedDirectory') : t(' · degraded directory')
: state.stale : state.stale
? t('layers.radio.state.staleDirectory') ? t(' · stale directory')
: ''; : '';
const outsideFilter = const outsideFilter =
selected && state.selectedIndex < 0 selected && state.selectedIndex < 0 ? t(' · outside current filter') : '';
? t('layers.radio.state.outsideFilter')
: '';
const messages = { const messages = {
stopped: enabled stopped: enabled
? t('layers.radio.state.ready') ? t('Ready — playback starts only from your action')
: t('layers.radio.playbackOff'), : t('Radio off'),
loading: t('layers.radio.state.loading'), loading: t('Connecting directly to broadcaster…'),
buffering: t('layers.radio.state.buffering'), buffering: t('Buffering broadcaster stream…'),
playing: t('layers.radio.state.playing', { station: stationName }), playing: t('Playing {station}', { station: stationName }),
paused: t('layers.radio.state.paused', { station: stationName }), paused: t('Paused {station}', { station: stationName }),
error: state.audioError || t('layers.radio.state.error'), error: state.audioError || t('Broadcaster stream unavailable'),
}; };
const voiceSuffix = state.voiceDucked const voiceSuffix = state.voiceDucked
? t('layers.radio.state.voiceMuted') ? t(' · muted during voice interaction')
: state.voiceRestoring : state.voiceRestoring
? t('layers.radio.state.voiceRestoring') ? t(' · restoring volume after voice')
: ''; : '';
const tuningSuffix = state.tuningAwaitingStationId const tuningSuffix = state.tuningAwaitingStationId
? state.audioState === 'error' ? state.audioState === 'error'
? t('layers.radio.state.staticNoAudio') ? t(' · static indicates no broadcaster audio')
: t('layers.radio.state.tuningStatic') : t(' · tuning static until broadcaster starts')
: ''; : '';
const unavailable = state.tuningUnavailableStationId const unavailable = state.tuningUnavailableStationId
? t('layers.radio.state.stationUnavailable') ? t(
'Station unavailable after directory refresh — choose another channel',
)
: null; : null;
const lifecycleMessage = transitioning const lifecycleMessage = transitioning
? lifecycleState === 'enabling' ? lifecycleState === 'enabling'
? t('layers.radio.state.enabling') ? t('Radio is enabling…')
: t('layers.radio.state.disabling') : t('Radio is disabling…')
: null; : null;
const uncertainMessage = uncertain const uncertainMessage = uncertain
? t('layers.radio.state.uncertain') ? t('Radio lifecycle is uncertain — use Enable or Disable to reconcile')
: null; : 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( this._radioPlaybackState.classList.toggle(
'error', 'error',
Boolean( Boolean(
+3 -5
View File
@@ -58,7 +58,7 @@ export class SceneControls {
if (!this.destroyed) elements.file.value = ''; if (!this.destroyed) elements.file.value = '';
}); });
if (!subscribe) { if (!subscribe) {
this.updateStatus(t('setup.scenes.statusReady')); this.updateStatus(t('Ready'));
this.setProgress(0); this.setProgress(0);
this.setButtons(false); this.setButtons(false);
} }
@@ -124,7 +124,7 @@ export class SceneControls {
const generation = ++this.actionGeneration; const generation = ++this.actionGeneration;
const failed = () => { const failed = () => {
if (!this.destroyed && generation === this.actionGeneration) if (!this.destroyed && generation === this.actionGeneration)
this.updateStatus(t('setup.scenes.status.actionFailed')); this.updateStatus(t('Scene action failed'));
}; };
try { try {
const result = this.actions[action](...args); const result = this.actions[action](...args);
@@ -163,9 +163,7 @@ export class SceneControls {
const shot = scene?.shots.find((item) => item.id === shotId); const shot = scene?.shots.find((item) => item.id === shotId);
if ( if (
shot && shot &&
window.confirm( window.confirm(t('Delete shot "{shot}"?', { shot: shot.title }))
t('setup.scenes.status.deleteShotConfirm', { shot: shot.title }),
)
) )
this.run('deleteShot', sceneId, shotId); this.run('deleteShot', sceneId, shotId);
} }
+9 -9
View File
@@ -189,12 +189,10 @@ export class ShareRestoration {
if (result.classification === 'pending') { if (result.classification === 'pending') {
this._shareTrackingNoticeGeneration += 1; this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = trackingKey; this._shareTrackingAcquiringKey = trackingKey;
this.showStatus(t('cockpit.status.acquiring'), { this.showStatus(t('ACQUIRING'), {
state: 'acquiring', state: 'acquiring',
detail: t('cockpit.status.sharedSubjectDetail', { detail: t('SHARED {subject}', {
subject: String( subject: String(result.label || t('entity')).toUpperCase(),
result.label || t('cockpit.status.subjectFallback'),
).toUpperCase(),
}), }),
persistent: true, persistent: true,
}); });
@@ -219,13 +217,15 @@ export class ShareRestoration {
const noticeGeneration = ownsAcquiringNotice const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration ? this._shareTrackingNoticeGeneration
: ++this._shareTrackingNoticeGeneration; : ++this._shareTrackingNoticeGeneration;
const subject = result.label || t('cockpit.status.subjectFallback'); const subject = result.label || t('entity');
const message = const message =
result.classification === 'expired' result.classification === 'expired'
? t('cockpit.status.sharedFollowExpired', { subject }) ? t('Shared {subject} follow expired', { subject })
: result.classification === 'source-unavailable' : result.classification === 'source-unavailable'
? t('cockpit.status.sharedRestoreFailed', { subject }) ? t('Shared {subject} could not be restored — feed unavailable', {
: t('cockpit.status.sharedUnavailable', { subject }); subject,
})
: t('Shared {subject} is unavailable', { subject });
const showAfterStartupCover = () => { const showAfterStartupCover = () => {
this._lifetime.frame(() => { this._lifetime.frame(() => {
if ( if (
+14 -14
View File
@@ -9,7 +9,7 @@ import {
reduceTrafficSyncFeedback, reduceTrafficSyncFeedback,
} from '../loadingFeedback.js'; } from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.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 // loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so // 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 // the closed label vocabulary to the catalog, and pass anything unrecognized
// (a live provider error) through verbatim (docs/TRANSLATORS.md). // (a live provider error) through verbatim (docs/TRANSLATORS.md).
const STATUS_LABEL_KEYS = Object.freeze({ const STATUS_LABEL_KEYS = Object.freeze({
'LOADING LIVE DATA': 'shell.status.loadingLiveData', 'LOADING LIVE DATA': msg('LOADING LIVE DATA'),
'LOAD COMPLETE': 'shell.status.loadComplete', 'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': 'shell.status.loadCancelled', 'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': 'shell.status.loadFailed', 'LOAD FAILED': msg('LOAD FAILED'),
'LIVE DATA OFF': 'shell.status.liveDataOff', 'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': 'shell.status.mappedSitesLoaded', 'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': 'shell.status.retryingMappedSites', 'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
'FETCHING MAPPED SITES': 'shell.status.fetchingMappedSites', 'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
'TURNING OFF LIVE DATA': 'shell.status.turningOffLiveData', 'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': 'shell.status.refreshingLiveData', 'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': 'shell.status.trafficSyncing', 'syncing road network': msg('syncing road network'),
}); });
function localizeStatusLabel(label) { function localizeStatusLabel(label) {
@@ -41,8 +41,8 @@ const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
function localizeStatusDetail(detail) { function localizeStatusDetail(detail) {
if (typeof detail !== 'string' || !detail) return detail; if (typeof detail !== 'string' || !detail) return detail;
const match = detail.match(RETRY_DETAIL_RE); const match = detail.match(RETRY_DETAIL_RE);
if (match) return t('shell.status.retryingIn', { seconds: match[1] }); if (match) return t('retrying in {seconds}s', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('shell.status.retryPending'); if (detail.trim() === 'retry pending') return t('retry pending');
return detail; return detail;
} }
+78 -80
View File
@@ -1,5 +1,5 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. --> <!-- 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. <!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. --> 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"> <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> <span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div> </div>
<div class="cockpit-horizon-guide"> <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>
<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"> <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" /> <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> </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>
<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-left" data-i18n>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-right" data-i18n>VISOR LOCK · ACTIVE</div>
</div> </div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim"> <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-rail"></div>
<div class="cockpit-altitude-rim-scale"> <div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span> <span data-speed-rim-tick><b></b><i></i></span>
@@ -92,7 +92,7 @@
</div> </div>
</div> </div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim"> <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-rail"></div>
<div class="cockpit-altitude-rim-scale"> <div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span> <span data-altitude-rim-tick><b></b><i></i></span>
@@ -112,36 +112,36 @@
</div> </div>
<div class="cockpit-topline"> <div class="cockpit-topline">
<div> <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> <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>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style" data-i18n-aria-label="cockpit.hud.visionGroupAriaLabel"> <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="cockpit.vision.previousAriaLabel" data-i18n-title="cockpit.vision.previousTitle"> <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> <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>
<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"> <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="cockpit.vision.current">CURRENT</small> <small data-i18n>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong> <strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
</button> </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"> <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="cockpit.vision.nextLabel">NEXT</small> <small data-i18n>NEXT</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span> <span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button> </button>
</div> </div>
</div> </div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</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-instrument-row">
<div class="cockpit-readout cockpit-speed"> <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> <strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span> <span class="cockpit-readout-unit">KTS</span>
</div> </div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div> <div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude"> <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> <strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span> <span class="cockpit-readout-unit">FT</span>
</div> </div>
@@ -153,26 +153,26 @@
<span id="cockpit-clock">--:--:--Z</span> <span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span> <span id="cockpit-position">--° · --°</span>
</div> </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"> <header class="cockpit-context-header">
<div> <div>
<span class="cockpit-context-kicker">CONTACT</span> <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> </div>
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label="cockpit.context.navAriaLabel"> <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="cockpit.context.previousAriaLabel" data-i18n-title="cockpit.context.previousTitle"> <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> <span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button> </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> <span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button> </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> <span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button> </button>
</nav> </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> </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="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="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> <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"> <div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span> <span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div> <div>
<small data-i18n="cockpit.context.nearestLabel">NEAREST OBSERVED / MAPPED</small> <small data-i18n>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" data-i18n="cockpit.context.nearestEmpty">NO AVAILABLE EXAMPLE</strong> <strong id="cockpit-context-nearest-label" data-i18n>NO AVAILABLE EXAMPLE</strong>
</div> </div>
<span id="cockpit-context-bearing">—</span> <span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span> <span id="cockpit-context-distance">—</span>
</div> </div>
<footer class="cockpit-context-footer"> <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 <button
id="cockpit-weather-toggle" id="cockpit-weather-toggle"
class="cockpit-context-weather" class="cockpit-context-weather"
type="button" type="button"
aria-label="Enable cockpit weather effects" aria-label="Enable cockpit weather effects"
aria-pressed="false" aria-pressed="false"
title="Enable cockpit weather effects" title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
data-i18n-aria-label="cockpit.context.weatherEnableAriaLabel" data-i18n-title="cockpit.context.weatherEnableTitle"
> >
<span>WX</span> <span>WX</span>
<strong id="cockpit-weather-state">OFF</strong> <strong id="cockpit-weather-state">OFF</strong>
@@ -204,35 +203,35 @@
<time id="cockpit-context-updated">--:--:--Z</time> <time id="cockpit-context-updated">--:--:--Z</time>
</footer> </footer>
</aside> </aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label="cockpit.brief.carouselAriaLabel"> <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="cockpit.route.cardAriaLabel"> <section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<div class="cockpit-route-heading"> <div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n="cockpit.route.kicker">ESTIMATED FLIGHT PLAN</span> <span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n="cockpit.route.statusUnavailable">ROUTE DATA UNAVAILABLE</small> <small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
</div> </div>
<div class="cockpit-route-endpoints"> <div class="cockpit-route-endpoints">
<div class="cockpit-route-airport"> <div class="cockpit-route-airport">
<span data-i18n="cockpit.route.fromLabel">FROM</span> <span data-i18n>FROM</span>
<strong id="cockpit-route-from" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong> <strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
</div> </div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span> <span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport"> <div class="cockpit-route-airport">
<span data-i18n="cockpit.route.toLabel">TO</span> <span data-i18n>TO</span>
<strong id="cockpit-route-to" data-i18n="cockpit.route.unknownEndpoint">UNKNOWN</strong> <strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
</div> </div>
</div> </div>
</section> </section>
<header class="cockpit-signal-header"> <header class="cockpit-signal-header">
<div class="cockpit-brief-heading"> <div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span> <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> </div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label="cockpit.brief.actionsAriaLabel"> <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="cockpit.brief.previousAriaLabel" data-i18n-title="cockpit.brief.previousTitle"> <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> <span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button> </button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span> <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> <span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button> </button>
<button <button
@@ -240,53 +239,52 @@
type="button" type="button"
aria-label="CYCLE OFF" aria-label="CYCLE OFF"
aria-pressed="false" 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." 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
data-i18n-title="cockpit.brief.autoTitle"
>CYCLE OFF</button> >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> <span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button> </button>
</nav> </nav>
</header> </header>
<div class="cockpit-brief-stage"> <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> <ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section> </section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label="cockpit.brief.newsAriaLabel"> <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="cockpit.brief.newsAcquiring">ACQUIRING REGIONAL NEWS</div> <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> <ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section> </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"> <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> <small id="cockpit-local-coordinates">--</small>
</div> </div>
<div class="cockpit-local-grid"> <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><span data-i18n>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><span data-i18n>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><span data-i18n>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>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
</div> </div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a> <a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
</section> </section>
</div> </div>
<footer class="cockpit-signal-footer"> <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"> <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="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="cockpit.brief.tabNewsAriaLabel">NEWS</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="cockpit.brief.tabLocalAriaLabel">LOCAL</button> <button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label>LOCAL</button>
</nav> </nav>
</footer> </footer>
</aside> </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-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display"> <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> <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>
<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="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div> <div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div> <div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
@@ -295,21 +293,21 @@
</div> </div>
<div class="cockpit-utility-control"> <div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio"> <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> <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>
<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"> <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="cockpit.radio.station.ready">READY</strong> <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="cockpit.radio.enable">ENABLE</button> <button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<div class="cockpit-radio-transport"> <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-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="cockpit.radio.playAriaLabel">▶</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="cockpit.radio.nextAriaLabel">▶▶</button> <button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled data-i18n-aria-label>▶▶</button>
</div> </div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume"> <label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span data-i18n="cockpit.radio.volumeLabel">VOLUME</span> <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="cockpit.radio.volumeAriaLabel" /> <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> <output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label> </label>
</div> </div>
@@ -318,13 +316,13 @@
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div> <div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
</section> </section>
<nav id="view-switcher" aria-label="View switcher" data-i18n-aria-label="cockpit.exit.navAriaLabel"> <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="cockpit.exit.resetAriaLabel" data-i18n-title="cockpit.exit.resetTitle"> <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 class="material-symbols-outlined" aria-hidden="true">public</span>
<span data-i18n="cockpit.exit.resetLabel">RESET</span> <span data-i18n>RESET</span>
</button> </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 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> </button>
</nav> </nav>
+27 -32
View File
@@ -1,58 +1,53 @@
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets --> <!-- 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 --> <!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed"> <div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="panel-inner"> <div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button" <button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false" 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-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span> <span class="panel-divider"></span>
</button> </button>
<!-- Language selector: only the group container is static (its <!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
data-i18n-aria-label is applied from the catalog at boot). The <div class="dock-locale-switch" role="group" data-i18n-aria-label aria-label="Language"></div>
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>
<div id="control-panel-popover" class="dock-popover-content"> <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"> <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-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> <span class="btn-key">1</span>
</button> </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-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> <span class="btn-key">2</span>
</button> </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-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> <span class="btn-key">3</span>
</button> </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-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> <span class="btn-key">4</span>
</button> </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-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> <span class="btn-key">5</span>
</button> </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-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> <span class="btn-key">6</span>
</button> </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-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> <span class="btn-key">7</span>
</button> </button>
</div> </div>
@@ -65,7 +60,7 @@
</section> </section>
</div> </div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite"> <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> <span id="style-mini-value" class="panel-mini-value">NORMAL</span>
</div> </div>
</div> </div>
@@ -76,26 +71,26 @@
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="location-inner"> <div class="location-inner">
<div class="location-toolbar"> <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> <span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button> </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>
<div id="location-mini-status" class="location-mini-status" aria-live="polite"> <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-city" class="location-mini-city" data-i18n>📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi" data-i18n="cockpit.location.miniPoiInitial">Landmark: --</div> <div id="location-mini-poi" class="location-mini-poi" data-i18n>Landmark: --</div>
</div> </div>
<div id="location-bar-popover" class="dock-popover-content"> <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 id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div> <div class="location-bar-divider" id="location-bar-divider"></div>
<div class="location-city-row"> <div class="location-city-row">
<div id="location-pills" class="location-pills"></div> <div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap"> <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> <span>&#x1F50E;</span>
</button> </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> </div>
</div> </div>
+52 -52
View File
@@ -28,112 +28,112 @@
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="global-context-panel-inner" data-rail-scroller> <div class="global-context-panel-inner" data-rail-scroller>
<div class="panel-header"> <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> <span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock"> <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> <span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button> </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"> <div class="context-radio-mini-heading">
<span data-i18n="cockpit.context.radioMiniTitle">RADIO</span> <span data-i18n>RADIO</span>
<strong id="context-radio-mini-station" data-i18n="cockpit.context.radioMiniReady">RADIO READY</strong> <strong id="context-radio-mini-station" data-i18n>RADIO READY</strong>
<span class="context-radio-mini-actions"> <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> <span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button> </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> <span class="material-symbols-outlined" aria-hidden="true">close</span>
</button> </button>
</span> </span>
</div> </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"> <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-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="cockpit.context.radioMiniPlayAriaLabel" data-i18n-title="cockpit.context.radioMiniPlayTitle">▶</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="cockpit.context.radioMiniNextAriaLabel" data-i18n-title="cockpit.context.radioMiniNextTitle">▶▶</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> </div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume"> <label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span data-i18n="cockpit.context.radioMiniVolumeLabel">VOLUME</span> <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="cockpit.context.radioMiniVolumeAriaLabel" /> <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> <output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label> </label>
</div> </div>
</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>
<div class="cyber-panel-body" data-rail-scroller> <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"> <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="cockpit.context.contactsTabAriaLabel" data-i18n-title="cockpit.context.contactsTabTitle"> <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 class="material-symbols-outlined" aria-hidden="true">radar</span>
<span data-i18n="cockpit.context.contactsTabLabel">CONTACTS</span> <span data-i18n>CONTACTS</span>
</button> </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"> <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 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> </button>
</div> </div>
<div id="context-mode-standby" class="context-mode-standby"> <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> <span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div> </div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden> <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> <button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span> <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>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" data-i18n="cockpit.context.searchNearbyLabel">SEARCH NEARBY SITES</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="cockpit.context.tr3bAriaLabel" data-i18n-title="cockpit.context.tr3bTitle">🛸</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> </div>
<aside id="military-awareness-panel"> <aside id="military-awareness-panel">
<div class="military-awareness-standby"> <div class="military-awareness-standby">
<strong data-i18n="cockpit.context.awarenessOffTitle">CONTACTS CONTEXT OFF</strong> <strong data-i18n>CONTACTS CONTEXT OFF</strong>
<span data-i18n="cockpit.context.awarenessOffHint">SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span> <span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div> </div>
</aside> </aside>
</section> </section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden> <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-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 class="space-mission-roster-header">
<div> <div>
<strong data-i18n="cockpit.context.rosterTitle">AVAILABLE MISSIONS</strong> <strong data-i18n>AVAILABLE MISSIONS</strong>
<span data-i18n="cockpit.context.rosterHint">SELECT A MISSION TO INSPECT</span> <span data-i18n>SELECT A MISSION TO INSPECT</span>
</div> </div>
<output data-mission-roster-count>—</output> <output data-mission-roster-count>—</output>
</div> </div>
<div class="space-mission-roster-list" data-mission-roster-list> <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> </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>
</div> </div>
</section> </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="radio-panel-inner">
<div class="panel-header"> <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 id="radio-layer-state" class="radio-layer-state">OFF</span>
<span class="panel-divider"></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>
<div class="radio-directory-row"> <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> <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="layers.radio.stationTagLabel">STATION TAG</label> <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="layers.radio.filterAriaLabel"> <select id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n="layers.radio.filterAll">All</option> <option value="all" data-i18n>All</option>
</select> </select>
</div> </div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite"> <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> <strong id="radio-station-name" data-i18n>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> <span id="radio-station-meta" data-i18n>Enable Radio, then choose a globe marker or use next.</span>
<span id="radio-station-tags"></span> <span id="radio-station-tags"></span>
</div> </div>
<div id="radio-tuner" class="radio-tuner" hidden> <div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header"> <div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span> <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>
<div class="radio-tuner-dial"> <div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div> <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 /> aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
</div> </div>
<div class="radio-tuner-readout"> <div class="radio-tuner-readout">
<span id="radio-tuner-station" data-i18n="layers.radio.tunerIdle">ALL · DRAG THE NEEDLE</span> <span id="radio-tuner-station" data-i18n>ALL · DRAG THE NEEDLE</span>
<span data-i18n="layers.radio.snapsNote">SNAPS TO AVAILABLE STATIONS</span> <span data-i18n>SNAPS TO AVAILABLE STATIONS</span>
</div> </div>
</div> </div>
<div class="radio-transport" role="group" aria-label="Radio playback" data-i18n-aria-label="layers.radio.transportAriaLabel"> <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="layers.radio.prevAriaLabel" data-i18n="layers.radio.prev">PREV</button> <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="layers.radio.playAriaLabel" data-i18n="layers.radio.play">PLAY</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="layers.radio.nextAriaLabel" data-i18n="layers.radio.next">NEXT</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="layers.radio.stopAriaLabel" data-i18n="layers.radio.stop">STOP</button> <button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled data-i18n-aria-label data-i18n>STOP</button>
</div> </div>
<label class="radio-volume-row" for="radio-volume"> <label class="radio-volume-row" for="radio-volume">
<span data-i18n="layers.radio.volumeLabel">VOLUME</span> <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="layers.radio.volumeAriaLabel" /> <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> <output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
</label> </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"> <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> <a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div> </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> <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 id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
<div class="sdr-radio-heading"> <div class="sdr-radio-heading">
+41 -41
View File
@@ -3,20 +3,20 @@
<div class="compact pp-header-row"> <div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span> <span class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></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>
<div class="pp-panel-body" data-rail-scroller> <div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group"> <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-icon">&#x25CE;</span>
<span class="pp-label">HUD</span> <span class="pp-label">HUD</span>
</button> </button>
<div class="pp-slider-row" id="hud-layout-row"> <div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.hudLayoutLabel">Layout</span> <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="cockpit.display.hudLayoutAriaLabel"> <select id="hud-layout-select" class="pp-select" aria-label="HUD layout" data-i18n-aria-label>
<option value="tactical" data-i18n="cockpit.display.hudLayoutTactical">Tactical</option> <option value="tactical" data-i18n>Tactical</option>
<option value="operator" data-i18n="cockpit.display.hudLayoutOperator">Operator</option> <option value="operator" data-i18n>Operator</option>
<option value="minimal" data-i18n="cockpit.display.hudLayoutMinimal">Minimal</option> <option value="minimal" data-i18n>Minimal</option>
<option value="cyber">Cyber</option> <option value="cyber">Cyber</option>
</select> </select>
</div> </div>
@@ -52,39 +52,39 @@
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <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-icon">&#x25A3;</span>
<span class="pp-label" data-i18n="cockpit.display.detectionLabel">DETECT</span> <span class="pp-label" data-i18n>DETECT</span>
</button> </button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row"> <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> <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="cockpit.display.densityAriaLabel" /> <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> <span class="pp-slider-value" id="detection-density-value">50%</span>
</div> </div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row"> <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> <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="cockpit.display.allocationAriaLabel"> <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="cockpit.display.allocationElastic">Elastic</button> <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="cockpit.display.allocationWeighted">Weighted</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> </div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row"> <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> <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="cockpit.display.fadeAriaLabel" data-i18n-title="cockpit.display.fadeTitle" /> <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> <span class="pp-slider-value" id="detection-fade-value">7%</span>
</div> </div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row"> <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> <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="cockpit.display.outsideAriaLabel" data-i18n-title="cockpit.display.outsideTitle" /> <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> <span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div> </div>
</div> </div>
<!-- The one shared preset-parameter surface is inserted here at runtime. --> <!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel"> <div id="param-slider-panel">
<div class="param-panel-header"> <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> <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>
<div id="param-sliders"></div> <div id="param-sliders"></div>
</div> </div>
@@ -93,31 +93,31 @@
button ships lit and the mode row ships open — the same markup-carries-the-default 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 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. --> 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-icon">&#x2708;</span>
<span class="pp-label">3D</span> <span class="pp-label">3D</span>
</button> </button>
<div class="pp-slider-row visible" id="models3d-mode-row"> <div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n="cockpit.display.modelsLabel">Models</span> <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="cockpit.display.modelsCoverageAriaLabel"> <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="cockpit.display.modelsModeProximity">Proximity</button> <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="cockpit.display.modelsModeAll">All</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>
</div> </div>
<div class="pp-toggle-group"> <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-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> </button>
<div class="pp-slider-row visible" id="scope-slider-row"> <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 <!-- 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); 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade softens the black mask edge; label fading is the separate Fade
slider. --> 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> <span class="pp-slider-value" id="scope-feather-value">11%</span>
</div> </div>
</div> </div>
@@ -148,35 +148,35 @@
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <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-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> </button>
</div> </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-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> </button>
<div class="pp-toggle-group"> <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-icon">&#x2728;</span>
<span class="pp-label" data-i18n="cockpit.display.bloomLabel">Bloom</span> <span class="pp-label" data-i18n>Bloom</span>
</button> </button>
<div class="pp-slider-row" id="bloom-slider-row"> <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> <span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div> </div>
</div> </div>
<div class="pp-toggle-group"> <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-icon">&#x1F50D;</span>
<span class="pp-label" data-i18n="cockpit.display.sharpenLabel">Sharpen</span> <span class="pp-label" data-i18n>Sharpen</span>
</button> </button>
<div class="pp-slider-row" id="sharpen-slider-row"> <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> <span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div> </div>
</div> </div>
</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"> <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> <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> <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>
</div> </div>
+43 -43
View File
@@ -4,9 +4,9 @@
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="data-panel-inner"> <div class="data-panel-inner">
<div class="panel-header"> <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> <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>
<div id="data-toggles" class="data-toggle-list"></div> <div id="data-toggles" class="data-toggle-list"></div>
</div> </div>
@@ -17,56 +17,56 @@
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller> <div class="cctv-panel-inner" data-rail-scroller>
<div class="panel-header"> <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> <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>
<div class="cyber-panel-body" data-rail-scroller> <div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap"> <div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" /> <img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas> <canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div> </div>
<div id="cctv-source-badge" role="status" aria-live="polite" data-i18n="layers.cctv.sourceUnknown">SOURCE · UNKNOWN</div> <div id="cctv-source-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
<div id="cctv-meta" data-i18n="layers.cctv.metaIdle">Enable CCTV to load camera intersections</div> <div id="cctv-meta" data-i18n>Enable CCTV to load camera intersections</div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn" data-i18n="layers.cctv.toggleOff">CCTV OFF</button> <button id="cctv-enable-btn" class="scene-btn" data-i18n>CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn" data-i18n="layers.cctv.nearest">NEAREST</button> <button id="cctv-nearest-btn" class="scene-btn" data-i18n>NEAREST</button>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn" data-i18n="layers.cctv.prev">PREV</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="layers.cctv.cameraAriaLabel"></select> <select id="cctv-camera-select" aria-label="CCTV camera" data-i18n-aria-label></select>
<button id="cctv-next-btn" class="scene-btn" data-i18n="layers.cctv.next">NEXT</button> <button id="cctv-next-btn" class="scene-btn" data-i18n>NEXT</button>
</div> </div>
<div class="cctv-controls"> <div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn" data-i18n="layers.cctv.focus">FOCUS</button> <button id="cctv-focus-btn" class="scene-btn" data-i18n>FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn" data-i18n="layers.cctv.coverageOff">COVERAGE OFF</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="layers.cctv.autoHopOff">AUTO HOP OFF</button> <button id="cctv-auto-hop-btn" class="scene-btn" data-i18n>AUTO HOP OFF</button>
</div> </div>
<div class="cctv-controls"> <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> <span id="cctv-quality-chip">CAL · --</span>
</div> </div>
<div class="cctv-calibration-block"> <div class="cctv-calibration-block">
<div class="cctv-calibration-header"> <div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n="layers.cctv.calibrationLabel">CALIBRATION</div> <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="layers.cctv.adjustTitle" data-i18n="layers.cctv.adjustLabel">ADJUST</button> <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>
<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"> <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="layers.cctv.calHeadingTitle">HDG --</button> <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="layers.cctv.calPitchTitle">PITCH --</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="layers.cctv.calFovTitle">FOV --</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="layers.cctv.calRangeTitle">RANGE --</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="layers.cctv.calHeightTitle">HGT --</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="layers.cctv.calNorthTitle">ΔN --</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="layers.cctv.calEastTitle">ΔE --</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>
<div class="cctv-controls cctv-calibration-actions"> <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-save-btn" class="scene-btn" data-i18n>SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn" data-i18n="layers.cctv.resetCal">RESET CAL</button> <button id="cctv-calib-reset-btn" class="scene-btn" data-i18n>RESET CAL</button>
</div> </div>
</div> </div>
<div class="cctv-summary-label" data-i18n="layers.cctv.summaryLabel">SCENE SUMMARY</div> <div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary" data-i18n="layers.cctv.summaryIdle">Enable CCTV to start camera-linked intelligence summaries.</div> <div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -145,35 +145,35 @@
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="scene-panel-inner"> <div class="scene-panel-inner">
<div class="panel-header"> <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> <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>
<div class="scene-controls"> <div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label="setup.scenes.recipeAriaLabel"></select> <select id="scene-select" aria-label="Scene recipe" data-i18n-aria-label></select>
<button id="scene-new-btn" class="scene-btn" data-i18n="setup.scenes.new">NEW</button> <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="setup.scenes.delete">DEL</button> <button id="scene-delete-btn" class="scene-btn scene-btn-danger" data-i18n>DEL</button>
</div> </div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn" data-i18n="setup.scenes.capture">CAPTURE 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="setup.scenes.updateShot">UPDATE SHOT</button> <button id="scene-update-shot-btn" class="scene-btn" data-i18n>UPDATE SHOT</button>
</div> </div>
<div id="scene-shot-list" class="scene-shot-list"></div> <div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-start-btn" class="scene-btn" data-i18n="setup.scenes.start">START</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="setup.scenes.stop">STOP</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="setup.scenes.next">NEXT</button> <button id="scene-next-btn" class="scene-btn" data-i18n>NEXT</button>
</div> </div>
<div class="scene-controls"> <div class="scene-controls">
<button id="scene-export-btn" class="scene-btn" data-i18n="setup.scenes.exportPresets">EXPORT PRESETS</button> <button id="scene-export-btn" class="scene-btn" data-i18n>EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn" data-i18n="setup.scenes.import">IMPORT</button> <button id="scene-import-btn" class="scene-btn" data-i18n>IMPORT</button>
<button id="scene-download-btn" class="scene-btn" data-i18n="setup.scenes.runLog">RUN LOG</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 /> <input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div> </div>
<div class="scene-progress"> <div class="scene-progress">
<div id="scene-progress-fill">0%</div> <div id="scene-progress-fill">0%</div>
</div> </div>
<div id="scene-status" data-i18n="setup.scenes.statusReady">Ready</div> <div id="scene-status" data-i18n>Ready</div>
</div> </div>
</div> </div>
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more